diff --git a/Riot/Assets/Base.lproj/Main.storyboard b/Riot/Assets/Base.lproj/Main.storyboard index 0fc069dcc..4708d7dbc 100644 --- a/Riot/Assets/Base.lproj/Main.storyboard +++ b/Riot/Assets/Base.lproj/Main.storyboard @@ -64,27 +64,6 @@ - - - - - - - - - - - - - - - - - - - - - @@ -98,9 +77,6 @@ - - - @@ -543,10 +519,29 @@ + + + + + + + + + + + + + + + + + + + + - diff --git a/Riot/Assets/Images.xcassets/Room/ContextMenu/Contents.json b/Riot/Assets/Images.xcassets/Room/ContextMenu/Contents.json index da4a164c9..73c00596a 100644 --- a/Riot/Assets/Images.xcassets/Room/ContextMenu/Contents.json +++ b/Riot/Assets/Images.xcassets/Room/ContextMenu/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Contents.json new file mode 100644 index 000000000..d2c033d2d --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Thread.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Thread@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Thread@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread.png b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread.png new file mode 100644 index 000000000..ec23ad509 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread.png differ diff --git a/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread@2x.png b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread@2x.png new file mode 100644 index 000000000..0fc08fd6b Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread@3x.png b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread@3x.png new file mode 100644 index 000000000..64c675d3c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/ContextMenu/room_context_menu_reply_in_thread.imageset/Thread@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Threads/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/Contents.json new file mode 100644 index 000000000..7c7666862 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "filter_list_black_24dp 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "filter_list_black_24dp 1@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "filter_list_black_24dp 1@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1.png b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1.png new file mode 100644 index 000000000..7710fa90e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1@2x.png b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1@2x.png new file mode 100644 index 000000000..b9cff9c1b Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1@3x.png b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1@3x.png new file mode 100644 index 000000000..5fec0029d Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Threads/threads_filter.imageset/filter_list_black_24dp 1@3x.png differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index e28693c92..75e34f0c7 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -338,7 +338,8 @@ Tap the + to start adding people."; "room_member_power_level_short_moderator" = "Mod"; "room_member_power_level_short_custom" = "Custom"; -// Chat +// MARK: - Chat + "room_slide_to_end_group_call" = "Slide to end the call for everyone"; "room_jump_to_first_unread" = "Jump to unread"; "room_accessiblity_scroll_to_bottom" = "Scroll to bottom"; @@ -378,7 +379,8 @@ Tap the + to start adding people."; "room_event_action_more" = "More"; "room_event_action_share" = "Share"; "room_event_action_forward" = "Forward"; -"room_event_action_permalink" = "Permalink"; +"room_event_action_view_in_room" = "View in room"; +"room_event_action_permalink" = "Copy link to message"; "room_event_action_view_source" = "View Source"; "room_event_action_view_decrypted_source" = "View Decrypted Source"; "room_event_action_report" = "Report content"; @@ -395,6 +397,7 @@ Tap the + to start adding people."; "room_event_action_cancel_download" = "Cancel Download"; "room_event_action_view_encryption" = "Encryption Information"; "room_event_action_reply" = "Reply"; +"room_event_action_reply_in_thread" = "Thread"; "room_event_action_edit" = "Edit"; "room_event_action_reaction_show_all" = "Show all"; "room_event_action_reaction_show_less" = "Show less"; @@ -423,12 +426,26 @@ Tap the + to start adding people."; "room_accessibility_upload" = "Upload"; "room_accessibility_call" = "Call"; "room_accessibility_video_call" = "Video Call"; +"room_accessibility_threads" = "Threads"; "room_accessibility_hangup" = "Hang up"; +"room_accessibility_thread_more" = "More"; "room_place_voice_call" = "Voice call"; "room_open_dialpad" = "Dial pad"; "room_join_group_call" = "Join"; "room_no_privileges_to_create_group_call" = "You need to be an admin or a moderator to start a call."; +// MARK: Threads +"room_thread_title" = "Thread"; +"thread_copy_link_to_thread" = "Copy link to thread"; +"threads_title" = "Threads"; +"threads_action_all_threads" = "All threads"; +"threads_action_my_threads" = "My threads"; +"threads_empty_title" = "Keep discussions organised with threads"; +"threads_empty_info_all" = "Threads help keep your conversations on-topic and easy to track."; +"threads_empty_info_my" = "Reply to an ongoing thread or use “Thread” when selecting a message to start a new one."; +"threads_empty_tip" = "Tip: Use “Thread” option when selecting a message."; +"threads_empty_show_all_threads" = "Show all threads"; + "media_type_accessibility_image" = "Image"; "media_type_accessibility_audio" = "Audio"; "media_type_accessibility_video" = "Video"; @@ -585,6 +602,7 @@ Tap the + to start adding people."; "settings_labs_message_reaction" = "React to messages with emoji"; "settings_labs_enable_ringing_for_group_calls" = "Ring for group calls"; "settings_labs_enabled_polls" = "Polls"; +"settings_labs_enable_threads" = "Threaded messaging"; "settings_version" = "Version %@"; "settings_olm_version" = "Olm Version %@"; @@ -919,6 +937,7 @@ Tap the + to start adding people."; "event_formatter_group_call_join" = "Join"; "event_formatter_group_call_leave" = "Leave"; "event_formatter_group_call_incoming" = "%@ in %@"; +"event_formatter_message_deleted" = "Message deleted"; // Events formatter with you "event_formatter_widget_added_by_you" = "You added the widget: %@"; diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 86ed4017a..cf52a37fe 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -134,7 +134,9 @@ internal enum Asset { internal static let roomContextMenuEdit = ImageAsset(name: "room_context_menu_edit") internal static let roomContextMenuMore = ImageAsset(name: "room_context_menu_more") internal static let roomContextMenuReply = ImageAsset(name: "room_context_menu_reply") + internal static let roomContextMenuReplyInThread = ImageAsset(name: "room_context_menu_reply_in_thread") internal static let roomContextMenuRetry = ImageAsset(name: "room_context_menu_retry") + internal static let roomContextMenuThread = ImageAsset(name: "room_context_menu_thread") internal static let inputCloseIcon = ImageAsset(name: "input_close_icon") internal static let inputEditIcon = ImageAsset(name: "input_edit_icon") internal static let inputReplyIcon = ImageAsset(name: "input_reply_icon") @@ -158,6 +160,7 @@ internal enum Asset { internal static let pollTypeCheckboxDefault = ImageAsset(name: "poll_type_checkbox_default") internal static let pollTypeCheckboxSelected = ImageAsset(name: "poll_type_checkbox_selected") internal static let pollWinnerIcon = ImageAsset(name: "poll_winner_icon") + internal static let threadsFilter = ImageAsset(name: "threads_filter") internal static let urlPreviewClose = ImageAsset(name: "url_preview_close") internal static let urlPreviewCloseDark = ImageAsset(name: "url_preview_close_dark") internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient") diff --git a/Riot/Generated/Storyboards.swift b/Riot/Generated/Storyboards.swift index 34615dd94..2a92564a0 100644 --- a/Riot/Generated/Storyboards.swift +++ b/Riot/Generated/Storyboards.swift @@ -279,6 +279,11 @@ internal enum StoryboardScene { internal static let initialScene = InitialSceneType(storyboard: TemplateScreenViewController.self) } + internal enum ThreadListViewController: StoryboardType { + internal static let storyboardName = "ThreadListViewController" + + internal static let initialScene = InitialSceneType(storyboard: ThreadListViewController.self) + } internal enum UserVerificationSessionStatusViewController: StoryboardType { internal static let storyboardName = "UserVerificationSessionStatusViewController" diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index bb758fd51..7ae71f3bb 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -1419,6 +1419,10 @@ public class VectorL10n: NSObject { public static func eventFormatterMemberUpdates(_ p1: Int) -> String { return VectorL10n.tr("Vector", "event_formatter_member_updates", p1) } + /// Message deleted + public static var eventFormatterMessageDeleted: String { + return VectorL10n.tr("Vector", "event_formatter_message_deleted") + } /// (edited) public static var eventFormatterMessageEditedMention: String { return VectorL10n.tr("Vector", "event_formatter_message_edited_mention") @@ -2687,6 +2691,14 @@ public class VectorL10n: NSObject { public static var roomAccessibilitySearch: String { return VectorL10n.tr("Vector", "room_accessibility_search") } + /// More + public static var roomAccessibilityThreadMore: String { + return VectorL10n.tr("Vector", "room_accessibility_thread_more") + } + /// Threads + public static var roomAccessibilityThreads: String { + return VectorL10n.tr("Vector", "room_accessibility_threads") + } /// Upload public static var roomAccessibilityUpload: String { return VectorL10n.tr("Vector", "room_accessibility_upload") @@ -3167,7 +3179,7 @@ public class VectorL10n: NSObject { public static var roomEventActionMore: String { return VectorL10n.tr("Vector", "room_event_action_more") } - /// Permalink + /// Copy link to message public static var roomEventActionPermalink: String { return VectorL10n.tr("Vector", "room_event_action_permalink") } @@ -3199,6 +3211,10 @@ public class VectorL10n: NSObject { public static var roomEventActionReply: String { return VectorL10n.tr("Vector", "room_event_action_reply") } + /// Thread + public static var roomEventActionReplyInThread: String { + return VectorL10n.tr("Vector", "room_event_action_reply_in_thread") + } /// Report content public static var roomEventActionReport: String { return VectorL10n.tr("Vector", "room_event_action_report") @@ -3231,6 +3247,10 @@ public class VectorL10n: NSObject { public static var roomEventActionViewEncryption: String { return VectorL10n.tr("Vector", "room_event_action_view_encryption") } + /// View in room + public static var roomEventActionViewInRoom: String { + return VectorL10n.tr("Vector", "room_event_action_view_in_room") + } /// View Source public static var roomEventActionViewSource: String { return VectorL10n.tr("Vector", "room_event_action_view_source") @@ -3799,6 +3819,10 @@ public class VectorL10n: NSObject { public static var roomSlideToEndGroupCall: String { return VectorL10n.tr("Vector", "room_slide_to_end_group_call") } + /// Thread + public static var roomThreadTitle: String { + return VectorL10n.tr("Vector", "room_thread_title") + } /// Invite members public static var roomTitleInviteMembers: String { return VectorL10n.tr("Vector", "room_title_invite_members") @@ -4787,6 +4811,10 @@ public class VectorL10n: NSObject { public static var settingsLabsEnableRingingForGroupCalls: String { return VectorL10n.tr("Vector", "settings_labs_enable_ringing_for_group_calls") } + /// Threaded messaging + public static var settingsLabsEnableThreads: String { + return VectorL10n.tr("Vector", "settings_labs_enable_threads") + } /// Polls public static var settingsLabsEnabledPolls: String { return VectorL10n.tr("Vector", "settings_labs_enabled_polls") @@ -5295,6 +5323,42 @@ public class VectorL10n: NSObject { public static var `switch`: String { return VectorL10n.tr("Vector", "switch") } + /// Copy link to thread + public static var threadCopyLinkToThread: String { + return VectorL10n.tr("Vector", "thread_copy_link_to_thread") + } + /// All threads + public static var threadsActionAllThreads: String { + return VectorL10n.tr("Vector", "threads_action_all_threads") + } + /// My threads + public static var threadsActionMyThreads: String { + return VectorL10n.tr("Vector", "threads_action_my_threads") + } + /// Threads help keep your conversations on-topic and easy to track. + public static var threadsEmptyInfoAll: String { + return VectorL10n.tr("Vector", "threads_empty_info_all") + } + /// Reply to an ongoing thread or use “Thread” when selecting a message to start a new one. + public static var threadsEmptyInfoMy: String { + return VectorL10n.tr("Vector", "threads_empty_info_my") + } + /// Show all threads + public static var threadsEmptyShowAllThreads: String { + return VectorL10n.tr("Vector", "threads_empty_show_all_threads") + } + /// Tip: Use “Thread” option when selecting a message. + public static var threadsEmptyTip: String { + return VectorL10n.tr("Vector", "threads_empty_tip") + } + /// Keep discussions organised with threads + public static var threadsEmptyTitle: String { + return VectorL10n.tr("Vector", "threads_empty_title") + } + /// Threads + public static var threadsTitle: String { + return VectorL10n.tr("Vector", "threads_title") + } /// Favourites public static var titleFavourites: String { return VectorL10n.tr("Vector", "title_favourites") diff --git a/Riot/Managers/Settings/RiotSettings.swift b/Riot/Managers/Settings/RiotSettings.swift index f672db25d..3f1eec4d6 100644 --- a/Riot/Managers/Settings/RiotSettings.swift +++ b/Riot/Managers/Settings/RiotSettings.swift @@ -136,6 +136,10 @@ final class RiotSettings: NSObject { @UserDefault(key: "enableRingingForGroupCalls", defaultValue: false, storage: defaults) var enableRingingForGroupCalls + /// Indicates if threads enabled in the timeline. + @UserDefault(key: "enableThreads", defaultValue: false, storage: defaults) + var enableThreads + // MARK: Calls /// Indicate if `allowStunServerFallback` settings has been set once. diff --git a/Riot/Modules/Application/LegacyAppDelegate.m b/Riot/Modules/Application/LegacyAppDelegate.m index fc7e503a2..54e0ecc52 100644 --- a/Riot/Modules/Application/LegacyAppDelegate.m +++ b/Riot/Modules/Application/LegacyAppDelegate.m @@ -1281,6 +1281,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni } NSString *roomIdOrAlias; + ThreadParameters *threadParameters; NSString *eventId; NSString *userId; NSString *groupId; @@ -1362,7 +1363,25 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni else { // Open the room page - RoomNavigationParameters *roomNavigationParameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:eventId mxSession:account.mxSession presentationParameters: screenPresentationParameters]; + if (eventId) + { + MXEvent *event = [account.mxSession.store eventWithEventId:eventId inRoom:roomId]; + if (event.threadId) + { + threadParameters = [[ThreadParameters alloc] initWithThreadId:event.threadId + stackRoomScreen:YES]; + } + else if ([account.mxSession.threadingService isEventThreadRoot:event]) + { + threadParameters = [[ThreadParameters alloc] initWithThreadId:event.eventId + stackRoomScreen:YES]; + } + } + RoomNavigationParameters *roomNavigationParameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId + eventId:eventId + mxSession:account.mxSession + threadParameters:threadParameters + presentationParameters:screenPresentationParameters]; [self showRoomWithParameters:roomNavigationParameters]; } @@ -2893,7 +2912,10 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni ScreenPresentationParameters *presentationParameters = [[ScreenPresentationParameters alloc] initWithRestoreInitialDisplay:YES]; RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId - eventId:eventId mxSession:mxSession presentationParameters:presentationParameters]; + eventId:eventId + mxSession:mxSession + threadParameters:nil + presentationParameters:presentationParameters]; [self showRoomWithParameters:parameters]; } @@ -3415,7 +3437,7 @@ NSString *const AppDelegateUniversalLinkDidChangeNotification = @"AppDelegateUni @"party_id": mxSession.myDeviceId }; - [mxSession.matrixRestClient sendEventToRoom:event.roomId eventType:kMXEventTypeStringCallReject content:content txnId:nil success:nil failure:^(NSError *error) { + [mxSession.matrixRestClient sendEventToRoom:event.roomId threadId:nil eventType:kMXEventTypeStringCallReject content:content txnId:nil success:nil failure:^(NSError *error) { MXLogDebug(@"[AppDelegate] enableNoVoIPOnMatrixSession: ERROR: Cannot send m.call.reject event."); }]; diff --git a/Riot/Modules/Application/ScreenNavigation/RoomNavigationParameters.swift b/Riot/Modules/Application/ScreenNavigation/RoomNavigationParameters.swift index 7308a5701..b44d00356 100644 --- a/Riot/Modules/Application/ScreenNavigation/RoomNavigationParameters.swift +++ b/Riot/Modules/Application/ScreenNavigation/RoomNavigationParameters.swift @@ -16,6 +16,24 @@ import Foundation +@objcMembers +class ThreadParameters: NSObject { + + /// If not nil, the thread will be opened on this room + let threadId: String + + /// If true, related room screen will be stacked in the navigation stack + let stackRoomScreen: Bool + + init(threadId: String, + stackRoomScreen: Bool) { + self.threadId = threadId + self.stackRoomScreen = stackRoomScreen + super.init() + } + +} + /// Navigation parameters to display a room with a provided identifier in a specific matrix session. @objcMembers class RoomNavigationParameters: NSObject { @@ -31,6 +49,9 @@ class RoomNavigationParameters: NSObject { /// The Matrix session in which the room should be available. let mxSession: MXSession + /// Navigation parameters for a thread + let threadParameters: ThreadParameters? + /// Screen presentation parameters. let presentationParameters: ScreenPresentationParameters @@ -39,10 +60,12 @@ class RoomNavigationParameters: NSObject { init(roomId: String, eventId: String?, mxSession: MXSession, + threadParameters: ThreadParameters?, presentationParameters: ScreenPresentationParameters) { self.roomId = roomId self.eventId = eventId self.mxSession = mxSession + self.threadParameters = threadParameters self.presentationParameters = presentationParameters super.init() diff --git a/Riot/Modules/Application/ScreenNavigation/RoomPreviewNavigationParameters.swift b/Riot/Modules/Application/ScreenNavigation/RoomPreviewNavigationParameters.swift index e59d2687f..3a9532c02 100644 --- a/Riot/Modules/Application/ScreenNavigation/RoomPreviewNavigationParameters.swift +++ b/Riot/Modules/Application/ScreenNavigation/RoomPreviewNavigationParameters.swift @@ -34,6 +34,7 @@ class RoomPreviewNavigationParameters: RoomNavigationParameters { super.init(roomId: previewData.roomId, eventId: previewData.eventId, mxSession: previewData.mxSession, + threadParameters: nil, presentationParameters: presentationParameters) } } diff --git a/Riot/Modules/Common/Presentation/CustomSizedPresentationController.swift b/Riot/Modules/Common/Presentation/CustomSizedPresentationController.swift index ce161ecec..554705d73 100644 --- a/Riot/Modules/Common/Presentation/CustomSizedPresentationController.swift +++ b/Riot/Modules/Common/Presentation/CustomSizedPresentationController.swift @@ -180,7 +180,13 @@ class CustomSizedPresentationController: UIPresentationController { } // return value from presentable if implemented - if let presentable = presentedViewController as? CustomSizedPresentable, let customSize = presentable.customSize?(withParentContainerSize: parentSize) { + if let presentable = presentedViewController as? CustomSizedPresentable, + let customSize = presentable.customSize?(withParentContainerSize: parentSize) { + return customSize + } + if let navController = presentedViewController as? UINavigationController, + let presentable = navController.viewControllers.first(where: { $0 is CustomSizedPresentable }) as? CustomSizedPresentable, + let customSize = presentable.customSize?(withParentContainerSize: parentSize) { return customSize } @@ -197,7 +203,13 @@ class CustomSizedPresentationController: UIPresentationController { withParentContainerSize: containerView.bounds.size) // use origin value from presentable if implemented - if let presentable = presentedViewController as? CustomSizedPresentable, let origin = presentable.position?(withParentContainerSize: containerView.bounds.size) { + if let presentable = presentedViewController as? CustomSizedPresentable, + let origin = presentable.position?(withParentContainerSize: containerView.bounds.size) { + return CGRect(origin: origin, size: size) + } + if let navController = presentedViewController as? UINavigationController, + let presentable = navController.viewControllers.first(where: { $0 is CustomSizedPresentable }) as? CustomSizedPresentable, + let origin = presentable.position?(withParentContainerSize: containerView.bounds.size) { return CGRect(origin: origin, size: size) } diff --git a/Riot/Modules/Common/Recents/RecentsViewController.m b/Riot/Modules/Common/Recents/RecentsViewController.m index 1e23a936c..41cfa0b0a 100644 --- a/Riot/Modules/Common/Recents/RecentsViewController.m +++ b/Riot/Modules/Common/Recents/RecentsViewController.m @@ -875,6 +875,7 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:nil mxSession:matrixSession + threadParameters:nil presentationParameters:presentationParameters]; [[AppDelegate theDelegate] showRoomWithParameters:parameters completion:^{ diff --git a/Riot/Modules/GlobalSearch/Files/HomeFilesSearchViewController.m b/Riot/Modules/GlobalSearch/Files/HomeFilesSearchViewController.m index 67ad65ab8..d9274d8be 100644 --- a/Riot/Modules/GlobalSearch/Files/HomeFilesSearchViewController.m +++ b/Riot/Modules/GlobalSearch/Files/HomeFilesSearchViewController.m @@ -157,6 +157,7 @@ RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:eventId mxSession:session + threadParameters:nil presentationParameters:presentationParameters]; [[AppDelegate theDelegate] showRoomWithParameters:parameters]; diff --git a/Riot/Modules/GlobalSearch/Messages/HomeMessagesSearchViewController.m b/Riot/Modules/GlobalSearch/Messages/HomeMessagesSearchViewController.m index 9fc1d5ac8..0ff484676 100644 --- a/Riot/Modules/GlobalSearch/Messages/HomeMessagesSearchViewController.m +++ b/Riot/Modules/GlobalSearch/Messages/HomeMessagesSearchViewController.m @@ -164,6 +164,7 @@ RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:eventId mxSession:session + threadParameters:nil presentationParameters:presentationParameters]; [[AppDelegate theDelegate] showRoomWithParameters:parameters]; diff --git a/Riot/Modules/GlobalSearch/Rooms/DirectoryViewController.m b/Riot/Modules/GlobalSearch/Rooms/DirectoryViewController.m index 66d724005..be2904f17 100644 --- a/Riot/Modules/GlobalSearch/Rooms/DirectoryViewController.m +++ b/Riot/Modules/GlobalSearch/Rooms/DirectoryViewController.m @@ -248,6 +248,7 @@ RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId eventId:nil mxSession:mxSession + threadParameters:nil presentationParameters:presentationParameters]; [[AppDelegate theDelegate] showRoomWithParameters:parameters]; } diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h index 2bd19ed7a..cd21ba7ea 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.h @@ -80,6 +80,11 @@ */ @property (nonatomic, readonly) BOOL hasLink; +/** + Whether the data has a thread root in its components. + */ +@property (nonatomic, readonly) BOOL hasThreadRoot; + /** Event formatter */ diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m index 84912efb1..6312b202a 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleCellData.m @@ -626,6 +626,22 @@ return NO; } +- (BOOL)hasThreadRoot +{ + @synchronized (bubbleComponents) + { + for (MXKRoomBubbleComponent *component in bubbleComponents) + { + if (component.thread) + { + return YES; + } + } + } + + return NO; +} + - (MXKRoomBubbleComponentDisplayFix)displayFix { MXKRoomBubbleComponentDisplayFix displayFix = MXKRoomBubbleComponentDisplayFixNone; diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h index 3912932c8..b778785e6 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.h @@ -19,6 +19,8 @@ #import "MXKEventFormatter.h" #import "MXKURLPreviewDataProtocol.h" +@class MXThread; + /** Flags to indicate if a fix is required at the display time. */ @@ -103,6 +105,11 @@ typedef enum : NSUInteger { */ @property (nonatomic, readonly) BOOL showEncryptionBadge; +/** + Thread for the bubble component. Should only exist for thread root events. + */ +@property (nonatomic, readonly) MXThread *thread; + /** Create a new `MXKRoomBubbleComponent` object based on a `MXEvent` instance. diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m index e40ffbf02..9a244040e 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomBubbleComponent.m @@ -18,6 +18,13 @@ #import "MXEvent+MatrixKit.h" #import "MXKSwiftHeader.h" +#import + +@interface MXKRoomBubbleComponent () + +@property (nonatomic, readwrite) MXThread *thread; + +@end @implementation MXKRoomBubbleComponent @@ -62,6 +69,8 @@ _showEncryptionBadge = [self shouldShowWarningBadgeForEvent:event roomState:(MXRoomState*)roomState session:session]; [self updateLinkWithRoomState:roomState]; + + self.thread = [session.threadingService threadWithId:event.eventId]; } return self; } diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h index 0510033a6..6bf9fef65 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.h @@ -169,6 +169,11 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; */ @property (nonatomic) NSString *partialTextMessage; +/** + The current thread id for the data source. If provided, data source displays the specified thread, otherwise the whole room messages. + */ +@property (nonatomic, readonly) NSString *threadId; + #pragma mark - Configuration /** The text formatter applied on the events. @@ -269,10 +274,15 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; @param roomId the id of the room to get data from. @param initialEventId the id of the event where to start the timeline. + @param threadId the id of the thread to load. If provided, thread data source will be loaded from the room specified with `roomId`. @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; ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId + initialEventId:(NSString*)initialEventId + threadId:(NSString*)threadId + andMatrixSession:(MXSession*)mxSession + onComplete:(void (^)(id roomDataSource))onComplete; /** Asynchronously create a data source to peek into a room. @@ -306,10 +316,14 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; @param roomId the id of the room to get data from. @param initialEventId the id of the event where to start the timeline. + @param threadId the id of the thread to initialize. If provided, thread data source will be initialized from the room specified with `roomId`. @param mxSession the Matrix session to get data from. @return the newly created instance. */ -- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession; +- (instancetype)initWithRoomId:(NSString*)roomId + initialEventId:(NSString*)initialEventId + threadId:(NSString*)threadId + andMatrixSession:(MXSession*)mxSession; /** Initialise the data source to peek into a room. @@ -697,6 +711,20 @@ extern NSString *const kMXKRoomDataSourceTimelineErrorErrorKey; */ + (dispatch_queue_t)processingQueue; +/** + Decides whether an event should be considered for asynchronous event processing. + Default implementation checks for `filterMessagesWithURL` and undecryptable events sent before the user joined. + Subclasses must call super at some point. + + @param event event to be processed or not + @param roomState the state of the room when the event fired + @param direction the direction of the event + @return YES to process the event, NO otherwise + */ +- (BOOL)shouldQueueEventForProcessing:(MXEvent*)event + roomState:(MXRoomState*)roomState + direction:(MXTimelineDirection)direction; + #pragma mark - Bubble collapsing /** diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m index a0da54d62..a46421ca2 100644 --- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m +++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m @@ -204,6 +204,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { @property (nonatomic, readwrite) MXRoom *secondaryRoom; @property (nonatomic, strong) MXEventTimeline *secondaryTimeline; +@property (nonatomic, readwrite) NSString *threadId; @end @@ -215,9 +216,9 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { [self ensureSessionStateForDataSource:roomDataSource initialEventId:nil andMatrixSession:mxSession onComplete:onComplete]; } -+ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete ++ (void)loadRoomDataSourceWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId threadId:(NSString*)threadId andMatrixSession:(MXSession*)mxSession onComplete:(void (^)(id roomDataSource))onComplete { - MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId initialEventId:initialEventId andMatrixSession:mxSession]; + MXKRoomDataSource *roomDataSource = [[self alloc] initWithRoomId:roomId initialEventId:initialEventId threadId:threadId andMatrixSession:mxSession]; [self ensureSessionStateForDataSource:roomDataSource initialEventId:initialEventId andMatrixSession:mxSession onComplete:onComplete]; } @@ -347,7 +348,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { return self; } -- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId2 andMatrixSession:(MXSession*)mxSession +- (instancetype)initWithRoomId:(NSString*)roomId initialEventId:(NSString*)initialEventId2 threadId:(NSString*)threadId andMatrixSession:(MXSession*)mxSession { self = [self initWithRoomId:roomId andMatrixSession:mxSession]; if (self) @@ -357,6 +358,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { initialEventId = initialEventId2; _isLive = NO; } + _threadId = threadId; } return self; @@ -364,7 +366,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { - (instancetype)initWithPeekingRoom:(MXPeekingRoom*)peekingRoom2 andInitialEventId:(NSString*)theInitialEventId { - self = [self initWithRoomId:peekingRoom2.roomId initialEventId:theInitialEventId andMatrixSession:peekingRoom2.mxSession]; + self = [self initWithRoomId:peekingRoom2.roomId initialEventId:theInitialEventId threadId:nil andMatrixSession:peekingRoom2.mxSession]; if (self) { peekingRoom = peekingRoom2; @@ -1289,6 +1291,34 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { } +- (BOOL)shouldQueueEventForProcessing:(MXEvent*)event roomState:(MXRoomState*)roomState direction:(MXTimelineDirection)direction +{ + 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 NO; + } + } + + // 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 NO; + } + } + + return YES; +} + #pragma mark - KVO - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context @@ -1425,7 +1455,10 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { // Launch the pagination MXWeakify(self); - paginationRequest = [_timeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{ + paginationRequest = [_timeline paginate:numItems + direction:direction + onlyFromStore:onlyFromStore + complete:^{ MXStrongifyAndReturnIfNil(self); @@ -1489,7 +1522,10 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { dispatch_group_enter(dispatchGroup); // Launch the pagination MXWeakify(self); - secondaryPaginationRequest = [_secondaryTimeline paginate:numItems direction:direction onlyFromStore:onlyFromStore complete:^{ + secondaryPaginationRequest = [_secondaryTimeline paginate:numItems + direction:direction + onlyFromStore:onlyFromStore + complete:^{ MXStrongifyAndReturnIfNil(self); @@ -1676,11 +1712,11 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { // Make the request to the homeserver if (isEmote) { - [_room sendEmote:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure]; + [_room sendEmote:sanitizedText formattedText:html threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; } else { - [_room sendTextMessage:sanitizedText formattedText:html localEcho:&localEchoEvent success:success failure:failure]; + [_room sendTextMessage:sanitizedText formattedText:html threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; } if (localEchoEvent) @@ -1821,7 +1857,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { { __block MXEvent *localEchoEvent = nil; - [_room sendImage:imageData withImageSize:imageSize mimeType:mimetype andThumbnail:thumbnail localEcho:&localEchoEvent success:success failure:failure]; + [_room sendImage:imageData withImageSize:imageSize mimeType:mimetype andThumbnail:thumbnail threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; if (localEchoEvent) { @@ -1841,7 +1877,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { { __block MXEvent *localEchoEvent = nil; - [_room sendVideoAsset:videoAsset withThumbnail:videoThumbnail localEcho:&localEchoEvent success:success failure:failure]; + [_room sendVideoAsset:videoAsset withThumbnail:videoThumbnail threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; if (localEchoEvent) { @@ -1855,7 +1891,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { { __block MXEvent *localEchoEvent = nil; - [_room sendAudioFile:audioFileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; + [_room sendAudioFile:audioFileLocalURL mimeType:mimeType threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; if (localEchoEvent) { @@ -1874,7 +1910,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { { __block MXEvent *localEchoEvent = nil; - [_room sendVoiceMessage:audioFileLocalURL mimeType:mimeType duration:duration samples:samples localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; + [_room sendVoiceMessage:audioFileLocalURL mimeType:mimeType duration:duration samples:samples threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure keepActualFilename:YES]; if (localEchoEvent) { @@ -1889,7 +1925,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { { __block MXEvent *localEchoEvent = nil; - [_room sendFile:fileLocalURL mimeType:mimeType localEcho:&localEchoEvent success:success failure:failure]; + [_room sendFile:fileLocalURL mimeType:mimeType threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; if (localEchoEvent) { @@ -1904,7 +1940,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { __block MXEvent *localEchoEvent = nil; // Make the request to the homeserver - [_room sendMessageWithContent:msgContent localEcho:&localEchoEvent success:success failure:failure]; + [_room sendMessageWithContent:msgContent threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; if (localEchoEvent) { @@ -1926,6 +1962,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { [_room sendLocationWithLatitude:latitude longitude:longitude description:description + threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; @@ -1942,7 +1979,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { __block MXEvent *localEchoEvent = nil; // Make the request to the homeserver - [_room sendEventOfType:eventTypeString content:msgContent localEcho:&localEchoEvent success:success failure:failure]; + [_room sendEventOfType:eventTypeString content:msgContent threadId:self.threadId localEcho:&localEchoEvent success:success failure:failure]; if (localEchoEvent) { @@ -1969,7 +2006,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { { // 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]; + [_room sendEventOfType:kMXEventTypeStringRoomEncrypted content:event.wireContent threadId:self.threadId localEcho:&event success:success failure:failure]; } else if ([event.type isEqualToString:kMXEventTypeStringRoomMessage]) { @@ -1978,7 +2015,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { 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]; + [_room sendMessageWithContent:event.content threadId:self.threadId localEcho:&event success:success failure:failure]; } else if ([msgType isEqualToString:kMXMessageTypeImage]) { @@ -2021,7 +2058,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { else { // Resend the Matrix event by reusing the existing echo - [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + [_room sendMessageWithContent:event.content threadId:self.threadId localEcho:&event success:success failure:failure]; } } else if ([msgType isEqualToString:kMXMessageTypeAudio]) @@ -2032,7 +2069,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { if (!contentURL || ![contentURL hasPrefix:kMXMediaUploadIdPrefix]) { // Resend the Matrix event by reusing the existing echo - [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + [_room sendMessageWithContent:event.content threadId:self.threadId localEcho:&event success:success failure:failure]; return; } @@ -2072,7 +2109,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { else { // Resend the Matrix event by reusing the existing echo - [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + [_room sendMessageWithContent:event.content threadId:self.threadId localEcho:&event success:success failure:failure]; } } else if ([msgType isEqualToString:kMXMessageTypeFile]) @@ -2108,7 +2145,7 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { else { // Resend the Matrix event by reusing the existing echo - [_room sendMessageWithContent:event.content localEcho:&event success:success failure:failure]; + [_room sendMessageWithContent:event.content threadId:self.threadId localEcho:&event success:success failure:failure]; } } else @@ -2805,27 +2842,9 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) { MXLogVerbose(@"[MXKRoomDataSource][%p] queueEventForProcessing: %@", self, event.eventId); } - if (self.filterMessagesWithURL) + if (![self shouldQueueEventForProcessing:event roomState:roomState direction:direction]) { - // 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; - } + return; } MXKQueuedEvent *queuedEvent = [[MXKQueuedEvent alloc] initWithEvent:event andRoomState:roomState direction:direction]; diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m index 1533da277..be5eec092 100644 --- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m +++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m @@ -325,11 +325,11 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; // Check first whether the event has been redacted NSString *redactedInfo = nil; - BOOL isRedacted = (event.redactedBecause != nil); + BOOL isRedacted = event.isRedactedEvent; if (isRedacted) { - // Check whether redacted information is required - if (_settings.showRedactionsInRoomHistory) + // Check whether the event is a thread root or redacted information is required + if ([mxSession.threadingService isEventThreadRoot:event] || _settings.showRedactionsInRoomHistory) { MXLogDebug(@"[MXKEventFormatter] Redacted event %@ (%@)", event.description, event.redactedBecause); @@ -1252,7 +1252,7 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; NSString *body; BOOL isHTML = NO; - NSString *eventThreadIdentifier = event.threadIdentifier; + NSString *eventThreadId = event.threadId; // Use the HTML formatted string if provided if ([event.content[@"format"] isEqualToString:kMXRoomMessageFormatHTML]) @@ -1260,17 +1260,17 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; isHTML =YES; MXJSONModelSetString(body, event.content[@"formatted_body"]); } - else if (eventThreadIdentifier) + else if (eventThreadId && !RiotSettings.shared.enableThreads) { isHTML = YES; MXJSONModelSetString(body, event.content[kMXMessageBodyKey]); - MXEvent *threadRootEvent = [mxSession.store eventWithEventId:eventThreadIdentifier + MXEvent *threadRootEvent = [mxSession.store eventWithEventId:eventThreadId inRoom:event.roomId]; NSString *threadRootEventContent; MXJSONModelSetString(threadRootEventContent, threadRootEvent.content[kMXMessageBodyKey]); body = [NSString stringWithFormat:@"
In reply to %@
%@
%@", - [MXTools permalinkToEvent:eventThreadIdentifier inRoom:event.roomId], + [MXTools permalinkToEvent:eventThreadId inRoom:event.roomId], [MXTools permalinkToUserWithUserId:threadRootEvent.sender], threadRootEvent.sender, threadRootEventContent, @@ -1361,7 +1361,7 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; // 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) + if ([relatesTo[@"m.in_reply_to"] isKindOfClass:NSDictionary.class] || (event.isInThread && !RiotSettings.shared.enableThreads)) { [attributedDisplayText enumerateAttribute:kMXKToolsBlockquoteMarkAttribute inRange:NSMakeRange(0, attributedDisplayText.length) @@ -1704,7 +1704,7 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; NSString *html = htmlString; // Special treatment for "In reply to" message - if (event.isReplyEvent || event.isInThread) + if (event.isReplyEvent || (event.isInThread && !RiotSettings.shared.enableThreads)) { html = [self renderReplyTo:html withRoomState:roomState]; } @@ -2030,7 +2030,7 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; textColor = _errorTextColor; } // Check whether the message is highlighted. - else if (event.mxkIsHighlighted || (event.isInThread && ![event.sender isEqualToString:mxSession.myUserId])) + else if (event.mxkIsHighlighted || (event.isInThread && !RiotSettings.shared.enableThreads && ![event.sender isEqualToString:mxSession.myUserId])) { textColor = _bingTextColor; } @@ -2094,7 +2094,7 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)"; { font = _callNoticesTextFont; } - else if (event.mxkIsHighlighted || (event.isInThread && ![event.sender isEqualToString:mxSession.myUserId])) + else if (event.mxkIsHighlighted || (event.isInThread && !RiotSettings.shared.enableThreads && ![event.sender isEqualToString:mxSession.myUserId])) { font = _bingTextFont; } diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m index cd0a25f62..80adf86e2 100644 --- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m +++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m @@ -294,6 +294,17 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat return [super hasNoDisplay]; } +- (BOOL)hasThreadRoot +{ + if (!RiotSettings.shared.enableThreads) + { + // do not consider this cell data if threads not enabled in the timeline + return NO; + } + + return super.hasThreadRoot; +} + #pragma mark - Bubble collapsing - (BOOL)collapseWith:(id)cellData @@ -564,6 +575,8 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat additionalVerticalHeight+= [self urlPreviewHeightForEventId:eventId]; // Add vertical whitespace in case of reactions. additionalVerticalHeight+= [self reactionHeightForEventId:eventId]; + // Add vertical whitespace in case of a thread root + additionalVerticalHeight+= [self threadSummaryViewHeightForEventId:eventId]; // Add vertical whitespace in case of read receipts. additionalVerticalHeight+= [self readReceiptHeightForEventId:eventId]; @@ -583,6 +596,7 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat height+= [self urlPreviewHeightForEventId:eventId]; height+= [self reactionHeightForEventId:eventId]; + height+= [self threadSummaryViewHeightForEventId:eventId]; height+= [self readReceiptHeightForEventId:eventId]; } @@ -621,6 +635,32 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat self.shouldUpdateAdditionalContentHeight = YES; } +- (CGFloat)threadSummaryViewHeightForEventId:(NSString*)eventId +{ + if (!RiotSettings.shared.enableThreads) + { + // do not show thread summary view if threads not enabled in the timeline + return 0; + } + if (roomDataSource.threadId) + { + // do not show thread summary view on threads + return 0; + } + NSInteger index = [self bubbleComponentIndexForEventId:eventId]; + if (index == NSNotFound) + { + return 0; + } + MXKRoomBubbleComponent *component = self.bubbleComponents[index]; + if (!component.thread) + { + // component is not a thread root + return 0; + } + return RoomBubbleCellLayout.threadSummaryViewTopMargin + [ThreadSummaryView contentViewHeightForThread:component.thread fitting:self.maxTextViewWidth]; +} + - (CGFloat)urlPreviewHeightForEventId:(NSString*)eventId { MXKRoomBubbleComponent *component = [self bubbleComponentWithLinkForEventId:eventId]; diff --git a/Riot/Modules/Room/ContextualMenu/RoomContextualMenuAction.swift b/Riot/Modules/Room/ContextualMenu/RoomContextualMenuAction.swift index 844ca035f..d138df3cf 100644 --- a/Riot/Modules/Room/ContextualMenu/RoomContextualMenuAction.swift +++ b/Riot/Modules/Room/ContextualMenu/RoomContextualMenuAction.swift @@ -19,6 +19,7 @@ import Foundation @objc enum RoomContextualMenuAction: Int { case copy case reply + case replyInThread case edit case more case resend @@ -34,6 +35,8 @@ import Foundation title = VectorL10n.roomEventActionCopy case .reply: title = VectorL10n.roomEventActionReply + case .replyInThread: + title = VectorL10n.roomEventActionReplyInThread case .edit: title = VectorL10n.roomEventActionEdit case .more: @@ -55,6 +58,8 @@ import Foundation image = Asset.Images.roomContextMenuCopy.image case .reply: image = Asset.Images.roomContextMenuReply.image + case .replyInThread: + image = Asset.Images.roomContextMenuReplyInThread.image case .edit: image = Asset.Images.roomContextMenuEdit.image case .more: diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.h b/Riot/Modules/Room/DataSources/RoomDataSource.h index 28af89097..eb3a3fd10 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.h +++ b/Riot/Modules/Room/DataSources/RoomDataSource.h @@ -33,7 +33,7 @@ /** The event id of the current selected event if any. Default is nil. */ -@property(nonatomic) NSString *selectedEventId; +@property(nonatomic, nullable) NSString *selectedEventId; /** Tell whether the initial event of the timeline (if any) must be marked. Default is NO. @@ -55,12 +55,18 @@ */ @property(nonatomic, nullable) NSArray *currentTypingUsers; +/** + Identifier of the event to be highlighted. Default is nil. + Data source owner should reload the view itself to reflect changes, and nullify the parameter afterwards when it doesn't highlight the event anymore. + */ +@property (nonatomic, nullable) NSString *highlightedEventId; + /** Check if there is an active jitsi widget in the room and return it. @return a widget representating the active jitsi conference in the room. Else, nil. */ -- (Widget *)jitsiWidget; +- (Widget * _Nullable)jitsiWidget; /** Send a video to the room. @@ -74,9 +80,9 @@ the event id of the event generated on the homeserver @param failure A block object called when the operation fails. */ -- (void)sendVideo:(NSURL*)videoLocalURL - success:(void (^)(NSString *eventId))success - failure:(void (^)(NSError *error))failure; +- (void)sendVideo:(NSURL * _Nonnull)videoLocalURL + success:(nullable void (^)(NSString * _Nonnull))success + failure:(nullable void (^)(NSError * _Nullable))failure; /** Accept incoming key verification request. @@ -85,9 +91,9 @@ @param success A block object called when the operation succeeds. @param failure A block object called when the operation fails. */ -- (void)acceptVerificationRequestForEventId:(NSString*)eventId - success:(void(^)(void))success - failure:(void(^)(NSError*))failure; +- (void)acceptVerificationRequestForEventId:(NSString * _Nonnull)eventId + success:(nullable void(^)(void))success + failure:(nullable void(^)(NSError * _Nullable))failure; /** Decline incoming key verification request. @@ -96,9 +102,9 @@ @param success A block object called when the operation succeeds. @param failure A block object called when the operation fails. */ -- (void)declineVerificationRequestForEventId:(NSString*)eventId - success:(void(^)(void))success - failure:(void(^)(NSError*))failure; +- (void)declineVerificationRequestForEventId:(NSString * _Nonnull)eventId + success:(nullable void(^)(void))success + failure:(nullable void(^)(NSError * _Nullable))failure; - (void)resetTypingNotification; @@ -106,9 +112,19 @@ @protocol RoomDataSourceDelegate -- (void)roomDataSource:(RoomDataSource*)roomDataSource didUpdateEncryptionTrustLevel:(RoomEncryptionTrustLevel)roomEncryptionTrustLevel; - -- (void)roomDataSource:(RoomDataSource*)roomDataSource didCancel:(MXEvent *)event; +/** + Called when the room's encryption trust level did update. + + @param roomDataSource room data source instance + */ +- (void)roomDataSourceDidUpdateEncryptionTrustLevel:(RoomDataSource * _Nonnull)roomDataSource; +/** + Called when a thread summary view is tapped. + + @param roomDataSource room data source instance + */ +- (void)roomDataSource:(RoomDataSource * _Nonnull)roomDataSource + didTapThread:(MXThread * _Nonnull)thread; @end diff --git a/Riot/Modules/Room/DataSources/RoomDataSource.m b/Riot/Modules/Room/DataSources/RoomDataSource.m index 0a3293b91..759afbf36 100644 --- a/Riot/Modules/Room/DataSources/RoomDataSource.m +++ b/Riot/Modules/Room/DataSources/RoomDataSource.m @@ -29,7 +29,7 @@ const CGFloat kTypingCellHeight = 24; -@interface RoomDataSource() +@interface RoomDataSource() { // Observe kThemeServiceDidChangeThemeNotification to handle user interface theme change. id kThemeServiceDidChangeThemeNotificationObserver; @@ -93,6 +93,11 @@ const CGFloat kTypingCellHeight = 24; }]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(newThreadCreated:) + name:MXThreadingService.newThreadCreated + object:nil]; + [self registerKeyVerificationRequestNotification]; [self registerKeyVerificationTransactionNotification]; [self registerTrustLevelDidChangeNotifications]; @@ -231,7 +236,7 @@ const CGFloat kTypingCellHeight = 24; - (void)fetchEncryptionTrustedLevel { self.encryptionTrustLevel = self.room.summary.roomEncryptionTrustLevel; - [self.roomDataSourceDelegate roomDataSource:self didUpdateEncryptionTrustLevel:self.encryptionTrustLevel]; + [self.roomDataSourceDelegate roomDataSourceDidUpdateEncryptionTrustLevel:self]; } - (void)roomDidSet @@ -239,6 +244,52 @@ const CGFloat kTypingCellHeight = 24; [self enableRoomCreationIntroCellDisplayIfNeeded]; } +- (BOOL)shouldQueueEventForProcessing:(MXEvent *)event roomState:(MXRoomState *)roomState direction:(MXTimelineDirection)direction +{ + if (self.threadId) + { + // if in a thread, ignore non-root event or events from other threads + if (![event.eventId isEqualToString:self.threadId] && ![event.threadId isEqualToString:self.threadId]) + { + // Ignore the event + return NO; + } + // also ignore events related to un-threaded or events from other threads + if (!event.isInThread && event.relatesTo.eventId) + { + MXEvent *relatedEvent = [self.mxSession.store eventWithEventId:event.relatesTo.eventId + inRoom:event.roomId]; + if (![relatedEvent.threadId isEqualToString:self.threadId]) + { + // ignore the event + return NO; + } + } + } + else if (RiotSettings.shared.enableThreads) + { + // if not in a thread, ignore all threaded events + if (event.threadId) + { + // ignore the event + return NO; + } + // also ignore events related to threaded events + if (event.relatesTo.eventId) + { + MXEvent *relatedEvent = [self.mxSession.store eventWithEventId:event.relatesTo.eventId + inRoom:event.roomId]; + if (relatedEvent.threadId) + { + // ignore the event + return NO; + } + } + } + + return [super shouldQueueEventForProcessing:event roomState:roomState direction:direction]; +} + #pragma mark - - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section @@ -342,7 +393,7 @@ const CGFloat kTypingCellHeight = 24; // Handle read receipts and read marker display. // Ignore the read receipts on the bubble without actual display. // Ignore the read receipts on collapsed bubbles - if ((((self.showBubbleReceipts && cellData.readReceipts.count) || cellData.reactions.count || cellData.hasLink) && !isCollapsableCellCollapsed) || self.showReadMarker) + if ((((self.showBubbleReceipts && cellData.readReceipts.count) || cellData.reactions.count || cellData.hasLink || cellData.hasThreadRoot) && !isCollapsableCellCollapsed) || self.showReadMarker) { // Read receipts container are inserted here on the right side into the content view. // Some vertical whitespaces are added in message text view (see RoomBubbleCellData class) to insert correctly multiple receipts. @@ -379,7 +430,6 @@ const CGFloat kTypingCellHeight = 24; urlPreviewView.tag = index; [temporaryViews addObject:urlPreviewView]; - [cellDecorator addURLPreviewView:urlPreviewView toCell:bubbleCell cellData:cellData contentViewPositionY:bottomPositionY]; } @@ -403,11 +453,29 @@ const CGFloat kTypingCellHeight = 24; bubbleReactionsViewModel.viewModelDelegate = self; [temporaryViews addObject:reactionsView]; - [cellDecorator addReactionView:reactionsView toCell:bubbleCell cellData:cellData contentViewPositionY:bottomPositionY upperDecorationView:urlPreviewView]; } + ThreadSummaryView *threadSummaryView; + + // display thread summary view if the component has a thread in the room timeline + if (RiotSettings.shared.enableThreads && component.thread && !self.threadId) + { + threadSummaryView = [[ThreadSummaryView alloc] initWithThread:component.thread]; + threadSummaryView.delegate = self; + threadSummaryView.tag = index; + + [temporaryViews addObject:threadSummaryView]; + UIView *upperDecorationView = reactionsView ?: urlPreviewView; + + [cellDecorator addThreadSummaryView:threadSummaryView + toCell:bubbleCell + cellData:cellData + contentViewPositionY:bottomPositionY + upperDecorationView:upperDecorationView]; + } + MXKReceiptSendersContainer* avatarsContainer; // Handle read receipts (if any) @@ -463,8 +531,7 @@ const CGFloat kTypingCellHeight = 24; avatarsContainer.accessibilityIdentifier = @"readReceiptsContainer"; [temporaryViews addObject:avatarsContainer]; - - UIView *upperDecorationView = reactionsView ?: urlPreviewView; + UIView *upperDecorationView = threadSummaryView ?: (reactionsView ?: urlPreviewView); [cellDecorator addReadReceiptsView:avatarsContainer toCell:bubbleCell @@ -563,14 +630,15 @@ const CGFloat kTypingCellHeight = 24; } // Manage initial event (case of permalink or search result) - if (self.timeline.initialEventId && self.markTimelineInitialEvent) + if ((self.timeline.initialEventId && self.markTimelineInitialEvent) || self.highlightedEventId) { // Check if the cell contains this initial event for (NSUInteger index = 0; index < bubbleComponents.count; index++) { MXKRoomBubbleComponent *component = bubbleComponents[index]; - if ([component.event.eventId isEqualToString:self.timeline.initialEventId]) + if ([component.event.eventId isEqualToString:self.timeline.initialEventId] + || [component.event.eventId isEqualToString:self.highlightedEventId]) { // If yes, mark the event [bubbleCell markComponent:index]; @@ -819,9 +887,9 @@ const CGFloat kTypingCellHeight = 24; return jitsiWidget; } -- (void)sendVideo:(NSURL*)videoLocalURL - success:(void (^)(NSString *eventId))success - failure:(void (^)(NSError *error))failure +- (void)sendVideo:(NSURL *)videoLocalURL + success:(void (^)(NSString * _Nonnull))success + failure:(void (^)(NSError * _Nullable))failure { AVURLAsset *videoAsset = [AVURLAsset assetWithURL:videoLocalURL]; UIImage *videoThumbnail = [MXKVideoThumbnailGenerator.shared generateThumbnailFrom:videoLocalURL]; @@ -924,6 +992,21 @@ const CGFloat kTypingCellHeight = 24; cell.attachmentView.accessibilityLabel = nil; } +#pragma mark - Threads + +- (void)newThreadCreated:(NSNotification *)notification +{ + NSUInteger count = 0; + @synchronized (bubbles) + { + count = bubbles.count; + } + if (count > 0) + { + [self reload]; + } +} + #pragma mark - BubbleReactionsViewModelDelegate - (void)bubbleReactionsViewModel:(BubbleReactionsViewModel *)viewModel didAddReaction:(MXReactionCount *)reactionCount forEventId:(NSString *)eventId @@ -1139,4 +1222,12 @@ const CGFloat kTypingCellHeight = 24; [self refreshCells]; } +#pragma mark - ThreadSummaryViewDelegate + +- (void)threadSummaryViewTapped:(ThreadSummaryView *)summaryView +{ + [self.roomDataSourceDelegate roomDataSource:self + didTapThread:summaryView.thread]; +} + @end diff --git a/Riot/Modules/Room/DataSources/ThreadDataSource.swift b/Riot/Modules/Room/DataSources/ThreadDataSource.swift new file mode 100644 index 000000000..6f67c1375 --- /dev/null +++ b/Riot/Modules/Room/DataSources/ThreadDataSource.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 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 + +@objcMembers +public class ThreadDataSource: RoomDataSource { + + + +} diff --git a/Riot/Modules/Room/MXKRoomViewController.h b/Riot/Modules/Room/MXKRoomViewController.h index 8ae86eccb..45ad325b5 100644 --- a/Riot/Modules/Room/MXKRoomViewController.h +++ b/Riot/Modules/Room/MXKRoomViewController.h @@ -437,4 +437,11 @@ typedef NS_ENUM(NSUInteger, MXKRoomViewControllerJoinRoomResult) { */ - (void)setBubbleTableViewContentOffset:(CGPoint)contentOffset animated:(BOOL)animated; +/** + Handle typing notification. + + @param typing Flag indicating whether the user is typing or not. + */ +- (void)handleTypingNotification:(BOOL)typing; + @end diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift index ef5116b17..fcf6d287a 100644 --- a/Riot/Modules/Room/RoomCoordinator.swift +++ b/Riot/Modules/Room/RoomCoordinator.swift @@ -72,7 +72,12 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { self.parameters = parameters self.selectedEventId = parameters.eventId - self.roomViewController = RoomViewController.instantiate() + if let threadId = parameters.threadId { + self.roomViewController = ThreadViewController.instantiate(withThreadId: threadId, + configuration: parameters.displayConfiguration) + } else { + self.roomViewController = RoomViewController.instantiate(with: parameters.displayConfiguration) + } self.activityIndicatorPresenter = ActivityIndicatorPresenter() if #available(iOS 14, *) { @@ -103,8 +108,13 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { if let previewData = self.parameters.previewData { self.loadRoomPreview(withData: previewData, completion: completion) + } else if let threadId = self.parameters.threadId { + self.loadRoom(withId: self.parameters.roomId, + andThreadId: threadId, + eventId: self.parameters.eventId, + completion: completion) } else if let eventId = self.selectedEventId { - self.loadRoom(withId: self.parameters.roomId, and: eventId, completion: completion) + self.loadRoom(withId: self.parameters.roomId, andEventId: eventId, completion: completion) } else { self.loadRoom(withId: self.parameters.roomId, completion: completion) } @@ -124,7 +134,7 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { self.selectedEventId = eventId if self.hasStartedOnce { - self.loadRoom(withId: self.parameters.roomId, and: eventId, completion: completion) + self.roomViewController.highlightAndDisplayEvent(eventId, completion: completion) } else { self.start(withCompletion: completion) } @@ -160,7 +170,7 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { }) } - private func loadRoom(withId roomId: String, and eventId: String, completion: (() -> Void)?) { + private func loadRoom(withId roomId: String, andEventId eventId: String, completion: (() -> Void)?) { // Present activity indicator when retrieving roomDataSource for given room ID self.activityIndicatorPresenter.presentActivityIndicator(on: roomViewController.view, animated: false) @@ -168,6 +178,7 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { // Open the room on the requested event RoomDataSource.load(withRoomId: roomId, initialEventId: eventId, + threadId: nil, andMatrixSession: self.parameters.session) { [weak self] (dataSource) in guard let self = self else { @@ -190,13 +201,45 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol { } } + private func loadRoom(withId roomId: String, andThreadId threadId: String, eventId: String?, completion: (() -> Void)?) { + + // Present activity indicator when retrieving roomDataSource for given room ID + self.activityIndicatorPresenter.presentActivityIndicator(on: roomViewController.view, animated: false) + + // Open the room on the requested event + ThreadDataSource.load(withRoomId: roomId, + initialEventId: nil, + threadId: threadId, + andMatrixSession: self.parameters.session) { [weak self] (dataSource) in + + guard let self = self else { + return + } + + self.activityIndicatorPresenter.removeCurrentActivityIndicator(animated: true) + + guard let threadDataSource = dataSource as? ThreadDataSource else { + return + } + + threadDataSource.markTimelineInitialEvent = false + threadDataSource.highlightedEventId = eventId + self.roomViewController.displayRoom(threadDataSource) + + // Give the data source ownership to the room view controller. + self.roomViewController.hasRoomDataSourceOwnership = true + + completion?() + } + } + private func loadRoomPreview(withData previewData: RoomPreviewData, completion: (() -> Void)?) { self.roomViewController.displayRoomPreview(previewData) completion?() } - + private func startLocationCoordinatorWithEvent(_ event: MXEvent? = nil, bubbleData: MXKRoomBubbleCellDataStoring? = nil) { guard #available(iOS 14.0, *) else { return @@ -278,6 +321,10 @@ extension RoomCoordinator: RoomIdentifiable { return self.parameters.roomId } + var threadId: String? { + return self.parameters.threadId + } + var mxSession: MXSession? { self.parameters.session } @@ -295,8 +342,8 @@ extension RoomCoordinator: UIAdaptivePresentationControllerDelegate { // MARK: - RoomViewControllerDelegate extension RoomCoordinator: RoomViewControllerDelegate { - func roomViewController(_ roomViewController: RoomViewController, showRoomWithId roomID: String) { - self.delegate?.roomCoordinator(self, didSelectRoomWithId: roomID) + func roomViewController(_ roomViewController: RoomViewController, showRoomWithId roomID: String, eventId eventID: String?) { + self.delegate?.roomCoordinator(self, didSelectRoomWithId: roomID, eventId: eventID) } func roomViewController(_ roomViewController: RoomViewController, showMemberDetails roomMember: MXRoomMember) { diff --git a/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift b/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift index d6ec2aa4e..bd6b70d77 100644 --- a/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift +++ b/Riot/Modules/Room/RoomCoordinatorBridgePresenter.swift @@ -18,7 +18,9 @@ import Foundation @objc protocol RoomCoordinatorBridgePresenterDelegate { func roomCoordinatorBridgePresenterDidLeaveRoom(_ bridgePresenter: RoomCoordinatorBridgePresenter) func roomCoordinatorBridgePresenterDidCancelRoomPreview(_ bridgePresenter: RoomCoordinatorBridgePresenter) - func roomCoordinatorBridgePresenter(_ bridgePresenter: RoomCoordinatorBridgePresenter, didSelectRoomWithId roomId: String) + func roomCoordinatorBridgePresenter(_ bridgePresenter: RoomCoordinatorBridgePresenter, + didSelectRoomWithId roomId: String, + eventId: String?) func roomCoordinatorBridgePresenterDidDismissInteractively(_ bridgePresenter: RoomCoordinatorBridgePresenter) } @@ -34,16 +36,26 @@ class RoomCoordinatorBridgePresenterParameters: NSObject { /// If not nil, the room will be opened on this event. let eventId: String? + /// If not nil, specified thread will be opened. + let threadId: String? + + /// Display configuration for the room + let displayConfiguration: RoomDisplayConfiguration + /// The data for the room preview. let previewData: RoomPreviewData? init(session: MXSession, roomId: String, eventId: String?, + threadId: String?, + displayConfiguration: RoomDisplayConfiguration, previewData: RoomPreviewData?) { self.session = session self.roomId = roomId self.eventId = eventId + self.threadId = threadId + self.displayConfiguration = displayConfiguration self.previewData = previewData } } @@ -60,6 +72,12 @@ final class RoomCoordinatorBridgePresenter: NSObject { private let bridgeParameters: RoomCoordinatorBridgePresenterParameters private var coordinator: RoomCoordinator? + private var navigationType: NavigationType = .present + + private enum NavigationType { + case present + case push + } // MARK: Public @@ -75,7 +93,6 @@ final class RoomCoordinatorBridgePresenter: NSObject { // MARK: - Public func present(from viewController: UIViewController, animated: Bool) { - let coordinator = self.createRoomCoordinator() coordinator.delegate = self let presentable = coordinator.toPresentable() @@ -84,6 +101,7 @@ final class RoomCoordinatorBridgePresenter: NSObject { coordinator.start() self.coordinator = coordinator + self.navigationType = .present } func push(from navigationController: UINavigationController, animated: Bool) { @@ -95,13 +113,25 @@ final class RoomCoordinatorBridgePresenter: NSObject { coordinator.start() // Will trigger view controller push self.coordinator = coordinator + self.navigationType = .push } func dismiss(animated: Bool, completion: (() -> Void)?) { guard let coordinator = self.coordinator else { return } - coordinator.toPresentable().dismiss(animated: animated) { + switch navigationType { + case .present: + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + completion?() + } + case .push: + guard let navigationController = coordinator.toPresentable().navigationController else { + return + } + navigationController.popViewController(animated: animated) self.coordinator = nil completion?() @@ -117,7 +147,12 @@ final class RoomCoordinatorBridgePresenter: NSObject { if let previewData = self.bridgeParameters.previewData { coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, previewData: previewData) } else { - coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, session: self.bridgeParameters.session, roomId: self.bridgeParameters.roomId, eventId: self.bridgeParameters.eventId) + coordinatorParameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, + session: self.bridgeParameters.session, + roomId: self.bridgeParameters.roomId, + eventId: self.bridgeParameters.eventId, + threadId: self.bridgeParameters.threadId, + displayConfiguration: self.bridgeParameters.displayConfiguration) } return RoomCoordinator(parameters: coordinatorParameters) @@ -127,8 +162,8 @@ final class RoomCoordinatorBridgePresenter: NSObject { // MARK: - RoomNotificationSettingsCoordinatorDelegate extension RoomCoordinatorBridgePresenter: RoomCoordinatorDelegate { - func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String) { - self.delegate?.roomCoordinatorBridgePresenter(self, didSelectRoomWithId: roomId) + func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String, eventId: String?) { + self.delegate?.roomCoordinatorBridgePresenter(self, didSelectRoomWithId: roomId, eventId: eventId) } func roomCoordinatorDidLeaveRoom(_ coordinator: RoomCoordinatorProtocol) { diff --git a/Riot/Modules/Room/RoomCoordinatorParameters.swift b/Riot/Modules/Room/RoomCoordinatorParameters.swift index fbc9f3511..13c47a039 100644 --- a/Riot/Modules/Room/RoomCoordinatorParameters.swift +++ b/Riot/Modules/Room/RoomCoordinatorParameters.swift @@ -37,6 +37,12 @@ struct RoomCoordinatorParameters { /// If not nil, the room will be opened on this event. let eventId: String? + /// If not nil, specified thread will be opened. + let threadId: String? + + /// Display configuration for the room + let displayConfiguration: RoomDisplayConfiguration + /// The data for the room preview. let previewData: RoomPreviewData? @@ -47,12 +53,16 @@ struct RoomCoordinatorParameters { session: MXSession, roomId: String, eventId: String?, + threadId: String?, + displayConfiguration: RoomDisplayConfiguration, previewData: RoomPreviewData?) { self.navigationRouter = navigationRouter self.navigationRouterStore = navigationRouterStore self.session = session self.roomId = roomId self.eventId = eventId + self.threadId = threadId + self.displayConfiguration = displayConfiguration self.previewData = previewData } @@ -61,9 +71,18 @@ struct RoomCoordinatorParameters { navigationRouterStore: NavigationRouterStoreProtocol? = nil, session: MXSession, roomId: String, - eventId: String? = nil) { + eventId: String? = nil, + threadId: String? = nil, + displayConfiguration: RoomDisplayConfiguration = .default) { - self.init(navigationRouter: navigationRouter, navigationRouterStore: navigationRouterStore, session: session, roomId: roomId, eventId: eventId, previewData: nil) + self.init(navigationRouter: navigationRouter, + navigationRouterStore: navigationRouterStore, + session: session, + roomId: roomId, + eventId: eventId, + threadId: threadId, + displayConfiguration: displayConfiguration, + previewData: nil) } /// Init to present a room preview @@ -71,6 +90,13 @@ struct RoomCoordinatorParameters { navigationRouterStore: NavigationRouterStoreProtocol? = nil, previewData: RoomPreviewData) { - self.init(navigationRouter: navigationRouter, navigationRouterStore: navigationRouterStore, session: previewData.mxSession, roomId: previewData.roomId, eventId: nil, previewData: previewData) + self.init(navigationRouter: navigationRouter, + navigationRouterStore: navigationRouterStore, + session: previewData.mxSession, + roomId: previewData.roomId, + eventId: nil, + threadId: nil, + displayConfiguration: .default, + previewData: previewData) } } diff --git a/Riot/Modules/Room/RoomCoordinatorProtocol.swift b/Riot/Modules/Room/RoomCoordinatorProtocol.swift index 1c30f02ec..9fe774ce6 100644 --- a/Riot/Modules/Room/RoomCoordinatorProtocol.swift +++ b/Riot/Modules/Room/RoomCoordinatorProtocol.swift @@ -21,7 +21,7 @@ import Foundation protocol RoomCoordinatorDelegate: AnyObject { func roomCoordinatorDidLeaveRoom(_ coordinator: RoomCoordinatorProtocol) func roomCoordinatorDidCancelRoomPreview(_ coordinator: RoomCoordinatorProtocol) - func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String) + func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String, eventId: String?) func roomCoordinatorDidDismissInteractively(_ coordinator: RoomCoordinatorProtocol) } diff --git a/Riot/Modules/Room/RoomDisplayConfiguration.swift b/Riot/Modules/Room/RoomDisplayConfiguration.swift new file mode 100644 index 000000000..2c3ab1ab8 --- /dev/null +++ b/Riot/Modules/Room/RoomDisplayConfiguration.swift @@ -0,0 +1,44 @@ +// +// Copyright 2021 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 + +@objcMembers +class RoomDisplayConfiguration: NSObject { + + let callsEnabled: Bool + + let integrationsEnabled: Bool + + let jitsiWidgetRemoverEnabled: Bool + + init(callsEnabled: Bool, + integrationsEnabled: Bool, + jitsiWidgetRemoverEnabled: Bool) { + self.callsEnabled = callsEnabled + self.integrationsEnabled = integrationsEnabled + self.jitsiWidgetRemoverEnabled = jitsiWidgetRemoverEnabled + super.init() + } + + static let `default`: RoomDisplayConfiguration = RoomDisplayConfiguration(callsEnabled: true, + integrationsEnabled: true, + jitsiWidgetRemoverEnabled: true) + + static let forThreads: RoomDisplayConfiguration = RoomDisplayConfiguration(callsEnabled: false, + integrationsEnabled: false, + jitsiWidgetRemoverEnabled: false) +} diff --git a/Riot/Modules/Room/RoomIdentifiable.swift b/Riot/Modules/Room/RoomIdentifiable.swift index 2ccac42d7..b86770360 100644 --- a/Riot/Modules/Room/RoomIdentifiable.swift +++ b/Riot/Modules/Room/RoomIdentifiable.swift @@ -20,5 +20,6 @@ import Foundation /// Useful to identify existing objects that should be removed when the user leaves a room for example. protocol RoomIdentifiable { var roomId: String? { get } + var threadId: String? { get } var mxSession: MXSession? { get } } diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h index 6e30853af..c47948655 100644 --- a/Riot/Modules/Room/RoomViewController.h +++ b/Riot/Modules/Room/RoomViewController.h @@ -30,6 +30,7 @@ @class BadgeLabel; @class UniversalLinkParameters; @protocol RoomViewControllerDelegate; +@class RoomDisplayConfiguration; NS_ASSUME_NONNULL_BEGIN @@ -72,6 +73,11 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; */ @property (nonatomic, readonly, nullable) RoomPreviewData *roomPreviewData; +/** + Display configuration for the room view controller. + */ +@property (nonatomic, readonly) RoomDisplayConfiguration *displayConfiguration; + /** Tell whether a badge must be added next to the chevron (back button) showing number of unread rooms. YES by default. @@ -94,12 +100,22 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; - (IBAction)scrollToBottomAction:(id)sender; +/** + Highlights an event in the timeline. Does not reload room data source if the event is already loaded. Otherwise, loads a new data source around the given event. + + @param eventId Identifier of the event to be highlighted. + @param completion Completion block to be called at the end of process. Optional. + */ +- (void)highlightAndDisplayEvent:(NSString *)eventId completion:(nullable void (^)(void))completion; + /** Creates and returns a new `RoomViewController` object. + @param configuration display configuration for the room view controller. + @return An initialized `RoomViewController` object. */ -+ (instancetype)instantiate; ++ (instancetype)instantiateWithConfiguration:(RoomDisplayConfiguration *)configuration; @end @@ -129,9 +145,11 @@ extern NSNotificationName const RoomGroupCallTileTappedNotification; @param roomViewController the `RoomViewController` instance. @param roomID the selected roomId + @param eventID the selected eventId */ - (void)roomViewController:(RoomViewController *)roomViewController - showRoomWithId:(NSString *)roomID; + showRoomWithId:(NSString *)roomID + eventId:(nullable NSString *)eventID; /** Tells the delegate that the user wants to start a direct chat with a user. diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m index 35117f3be..4fceaf479 100644 --- a/Riot/Modules/Room/RoomViewController.m +++ b/Riot/Modules/Room/RoomViewController.m @@ -93,7 +93,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @interface RoomViewController () + RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, UserSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate> { // The preview header @@ -198,6 +198,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @property (nonatomic, strong) RoomCreationModalCoordinatorBridgePresenter *roomCreationModalCoordinatorBridgePresenter; @property (nonatomic, strong) RoomInfoCoordinatorBridgePresenter *roomInfoCoordinatorBridgePresenter; @property (nonatomic, strong) CustomSizedPresentationController *customSizedPresentationController; +@property (nonatomic, strong) ThreadsCoordinatorBridgePresenter *threadsBridgePresenter; @property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded; @property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden; @property (nonatomic, getter=isMissedDiscussionsBadgeHidden) BOOL missedDiscussionsBadgeHidden; @@ -210,6 +211,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; @property (nonatomic, strong) UserSuggestionCoordinatorBridge *userSuggestionCoordinator; @property (nonatomic, weak) IBOutlet UIView *userSuggestionContainerView; +@property (nonatomic, readwrite) RoomDisplayConfiguration *displayConfiguration; @property (nonatomic) AnalyticsScreenTimer *screenTimer; @end @@ -227,14 +229,29 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; + (instancetype)roomViewController { - return [[[self class] alloc] initWithNibName:NSStringFromClass(self.class) - bundle:[NSBundle bundleForClass:self.class]]; + RoomViewController *controller = [[[self class] alloc] initWithNibName:NSStringFromClass(self.class) + bundle:[NSBundle bundleForClass:self.class]]; + controller.displayConfiguration = [RoomDisplayConfiguration default]; + return controller; } -+ (instancetype)instantiate ++ (instancetype)instantiateWithConfiguration:(RoomDisplayConfiguration *)configuration { UIStoryboard *storyboard = [UIStoryboard storyboardWithName:@"Main" bundle:[NSBundle mainBundle]]; - return [storyboard instantiateViewControllerWithIdentifier:@"RoomViewControllerStoryboardId"]; + NSString *storyboardId = [NSString stringWithFormat:@"%@StoryboardId", self.className]; + RoomViewController *controller = [storyboard instantiateViewControllerWithIdentifier:storyboardId]; + controller.displayConfiguration = configuration; + return controller; +} + ++ (NSString *)className +{ + NSString *result = NSStringFromClass(self.class); + if ([result containsString:@"."]) + { + result = [result componentsSeparatedByString:@"."].lastObject; + } + return result; } #pragma mark - @@ -1386,8 +1403,34 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; return item; } +- (UIBarButtonItem *)threadMoreBarButtonItem +{ + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"room_context_menu_more"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(onButtonPressed:)]; + item.accessibilityLabel = [VectorL10n roomAccessibilityThreadMore]; + + return item; +} + +- (UIBarButtonItem *)threadListBarButtonItem +{ + UIBarButtonItem *item = [[UIBarButtonItem alloc] initWithImage:[UIImage imageNamed:@"room_context_menu_reply_in_thread"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(onThreadListTapped:)]; + item.accessibilityLabel = [VectorL10n roomAccessibilityThreads]; + return item; +} + - (void)setupRemoveJitsiWidgetRemoveView { + if (!self.displayConfiguration.jitsiWidgetRemoverEnabled) + { + return; + } + self.removeJitsiWidgetView = [RemoveJitsiWidgetView instantiate]; self.removeJitsiWidgetView.delegate = self; @@ -1430,6 +1473,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (BOOL)supportCallOption { + if (!self.displayConfiguration.callsEnabled) + { + return NO; + } BOOL callOptionAllowed = (self.roomDataSource.room.isDirect && RiotSettings.shared.roomScreenAllowVoIPForDirectRoom) || (!self.roomDataSource.room.isDirect && RiotSettings.shared.roomScreenAllowVoIPForNonDirectRoom); return callOptionAllowed && BuildSettings.allowVoIPUsage && self.roomDataSource.mxSession.callManager && self.roomDataSource.room.summary.membersCount.joined >= 2; } @@ -1593,12 +1640,12 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } [rightBarButtonItems addObject:item]; } - - // Do not change title view class here if the expanded header is visible. - [self setRoomTitleViewClass:RoomTitleView.class]; - ((RoomTitleView*)self.titleView).tapGestureDelegate = self; } + // Do not change title view class here if the expanded header is visible. + [self setRoomTitleViewClass:RoomTitleView.class]; + ((RoomTitleView*)self.titleView).tapGestureDelegate = self; + MXKImageView *userPictureView = ((RoomTitleView*)self.titleView).pictureView; // Set user picture in input toolbar @@ -1608,6 +1655,22 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } [self refreshMissedDiscussionsCount:YES]; + + if (RiotSettings.shared.enableThreads) + { + if (self.roomDataSource.threadId) + { + // in a thread + UIBarButtonItem *itemThreadMore = [self threadMoreBarButtonItem]; + [rightBarButtonItems insertObject:itemThreadMore atIndex:0]; + } + else + { + // in a regular timeline + UIBarButtonItem *itemThreadList = [self threadListBarButtonItem]; + [rightBarButtonItems insertObject:itemThreadList atIndex:0]; + } + } } self.navigationItem.rightBarButtonItems = rightBarButtonItems; @@ -2067,7 +2130,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { if (self.delegate) { - [self.delegate roomViewController:self showRoomWithId:roomId]; + [self.delegate roomViewController:self showRoomWithId:roomId eventId:nil]; } else { @@ -3109,6 +3172,22 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; MXWeakify(self); UIAlertController *actionsMenu = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + BOOL showThreadOption = RiotSettings.shared.enableThreads + && !self.roomDataSource.threadId + && !selectedEvent.threadId; + if (showThreadOption && [self canCopyEvent:selectedEvent andCell:cell]) + { + [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionCopy] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + MXStrongifyAndReturnIfNil(self); + + [self cancelEventSelection]; + + [self copyEvent:selectedEvent inCell:cell]; + }]]; + } + // Add actions for a failed event if (selectedEvent.sentState == MXEventSentStateFailed) { @@ -3124,7 +3203,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]]; [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionDelete] - style:UIAlertActionStyleDefault + style:UIAlertActionStyleDestructive handler:^(UIAlertAction * action) { MXStrongifyAndReturnIfNil(self); @@ -3170,6 +3249,20 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]]; } + if (self.roomDataSource.threadId && [selectedEvent.eventId isEqualToString:self.roomDataSource.threadId]) + { + // if in the thread and selected event is the root event + // add "View in room" action + [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewInRoom] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction * action) { + MXStrongifyAndReturnIfNil(self); + [self.delegate roomViewController:self + showRoomWithId:self.roomDataSource.roomId + eventId:selectedEvent.eventId]; + }]]; + } + if (selectedEvent.sentState == MXEventSentStateSent && selectedEvent.eventType != MXEventTypePollStart && !selectedEvent.location) @@ -3378,7 +3471,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]]; } } - + // Do not allow to redact the event that enabled encryption (m.room.encryption) // because it breaks everything if (selectedEvent.eventType != MXEventTypeRoomEncryption) @@ -3417,7 +3510,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; }]]; } - if (selectedEvent.eventType == MXEventTypePollStart && [selectedEvent.sender isEqualToString:self.mainSession.myUser.userId]) { + if (selectedEvent.eventType == MXEventTypePollStart && [selectedEvent.sender isEqualToString:self.mainSession.myUserId]) { if ([self.delegate roomViewController:self canEndPollWithEventIdentifier:selectedEvent.eventId]) { [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionEndPoll] style:UIAlertActionStyleDefault @@ -3507,7 +3600,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } } - if (![selectedEvent.sender isEqualToString:self.mainSession.myUser.userId] && RiotSettings.shared.roomContextualMenuShowReportContentOption) + if (![selectedEvent.sender isEqualToString:self.mainSession.myUserId] && RiotSettings.shared.roomContextualMenuShowReportContentOption) { [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionReport] style:UIAlertActionStyleDefault @@ -3872,6 +3965,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; customizedRoomDataSource.showBubbleDateTimeOnSelection = YES; customizedRoomDataSource.selectedEventId = nil; + customizedRoomDataSource.highlightedEventId = nil; [self restoreTextMessageBeforeEditing]; @@ -3926,12 +4020,17 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; #pragma mark - RoomDataSourceDelegate -- (void)roomDataSource:(RoomDataSource *)roomDataSource didUpdateEncryptionTrustLevel:(RoomEncryptionTrustLevel)roomEncryptionTrustLevel +- (void)roomDataSourceDidUpdateEncryptionTrustLevel:(RoomDataSource *)roomDataSource { [self updateInputToolbarEncryptionDecoration]; [self updateTitleViewEncryptionDecoration]; } +- (void)roomDataSource:(RoomDataSource *)roomDataSource didTapThread:(MXThread *)thread +{ + [self openThreadWithId:thread.id]; +} + #pragma mark - Segues - (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender @@ -4239,6 +4338,15 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self placeCallWithVideo:YES]; } +- (IBAction)onThreadListTapped:(id)sender +{ + self.threadsBridgePresenter = [[ThreadsCoordinatorBridgePresenter alloc] initWithSession:self.mainSession + roomId:self.roomDataSource.roomId + threadId:nil]; + self.threadsBridgePresenter.delegate = self; + [self.threadsBridgePresenter pushFrom:self.navigationController animated:YES]; +} + - (IBAction)onIntegrationsPressed:(id)sender { WidgetPickerViewController *widgetPicker = [[WidgetPickerViewController alloc] initForMXSession:self.roomDataSource.mxSession @@ -4261,7 +4369,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; // Jump to the last unread event by using a temporary room data source initialized with the last unread event id. MXWeakify(self); - [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId initialEventId:self.roomDataSource.room.accountData.readMarkerEventId andMatrixSession:self.mainSession onComplete:^(id roomDataSource) { + [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId + initialEventId:self.roomDataSource.room.accountData.readMarkerEventId + threadId:self.roomDataSource.threadId + andMatrixSession:self.mainSession + onComplete:^(id roomDataSource) { MXStrongifyAndReturnIfNil(self); [roomDataSource finalizeInitialization]; @@ -4360,6 +4472,25 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; { [super scrollViewWillBeginDragging:scrollView]; } + + // if data source is highlighting an event, dismiss the highlight when user drags the table view + if (customizedRoomDataSource.highlightedEventId) + { + NSInteger row = [self.roomDataSource indexOfCellDataWithEventId:customizedRoomDataSource.highlightedEventId]; + if (row == NSNotFound) + { + customizedRoomDataSource.highlightedEventId = nil; + return; + } + + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; + if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) + { + customizedRoomDataSource.highlightedEventId = nil; + [self.bubblesTableView reloadRowsAtIndexPaths:@[indexPath] + withRowAnimation:UITableViewRowAnimationAutomatic]; + } + } } - (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate @@ -4484,7 +4615,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; if (eventId) { MXWeakify(self); - [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId initialEventId:eventId andMatrixSession:self.mainSession onComplete:^(id roomDataSource) { + [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId + initialEventId:eventId + threadId:self.roomDataSource.threadId + andMatrixSession:self.mainSession + onComplete:^(id roomDataSource) { MXStrongifyAndReturnIfNil(self); [roomDataSource finalizeInitialization]; @@ -4766,6 +4901,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)listenWidgetNotifications { + if (!self.displayConfiguration.jitsiWidgetRemoverEnabled) + { + return; + } + MXWeakify(self); kMXKWidgetManagerDidUpdateWidgetObserver = [[NSNotificationCenter defaultCenter] addObserverForName:kWidgetManagerDidUpdateWidgetNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification *notif) { @@ -4802,6 +4942,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (NSUInteger)widgetsCount:(BOOL)includeUserWidgets { + if (!self.displayConfiguration.integrationsEnabled) + { + return 0; + } + NSUInteger widgetsCount = [[WidgetManager sharedManager] widgetsNotOfTypes:@[kWidgetTypeJitsiV1, kWidgetTypeJitsiV2] inRoom:self.roomDataSource.room withRoomState:self.roomDataSource.roomState].count; @@ -5434,6 +5579,11 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; - (void)refreshRemoveJitsiWidgetView { + if (!self.displayConfiguration.jitsiWidgetRemoverEnabled) + { + return; + } + if (self.roomDataSource.isLive && !self.roomDataSource.isPeeking) { Widget *jitsiWidget = [customizedRoomDataSource jitsiWidget]; @@ -5814,25 +5964,29 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; ]; } - BOOL showMoreOption = (event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForStates) || (!event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForMessages); + BOOL showMoreOption = (event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForStates) + || (!event.isState && RiotSettings.shared.roomContextualMenuShowMoreOptionForMessages); + BOOL showThreadOption = RiotSettings.shared.enableThreads && !self.roomDataSource.threadId && !event.threadId; + NSMutableArray *items = [NSMutableArray arrayWithCapacity:5]; + + if (!showThreadOption) + { + [items addObject:[self copyMenuItemWithEvent:event andCell:cell]]; + } + [items addObject:[self replyMenuItemWithEvent:event]]; + if (showThreadOption) + { + // add "Thread" option only if not already in a thread + [items addObject:[self replyInThreadMenuItemWithEvent:event]]; + } + [items addObject:[self editMenuItemWithEvent:event]]; if (showMoreOption) { - return @[ - [self copyMenuItemWithEvent:event andCell:cell], - [self replyMenuItemWithEvent:event], - [self editMenuItemWithEvent:event], - [self moreMenuItemWithEvent:event andCell:cell] - ]; - } - else - { - return @[ - [self copyMenuItemWithEvent:event andCell:cell], - [self replyMenuItemWithEvent:event], - [self editMenuItemWithEvent:event] - ]; + [items addObject:[self moreMenuItemWithEvent:event andCell:cell]]; } + + return items; } - (void)showContextualMenuForEvent:(MXEvent*)event fromSingleTapGesture:(BOOL)usedSingleTapGesture cell:(id)cell animated:(BOOL)animated @@ -6025,20 +6179,33 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } - (RoomContextualMenuItem *)copyMenuItemWithEvent:(MXEvent*)event andCell:(id)cell +{ + MXWeakify(self); + + RoomContextualMenuItem *copyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionCopy]; + copyMenuItem.isEnabled = [self canCopyEvent:event andCell:cell]; + copyMenuItem.action = ^{ + MXStrongifyAndReturnIfNil(self); + + [self copyEvent:event inCell:cell]; + }; + + return copyMenuItem; +} + +- (BOOL)canCopyEvent:(MXEvent*)event andCell:(id)cell { MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; - MXWeakify(self); - - BOOL isCopyActionEnabled = (event.eventType != MXEventTypePollStart && (!attachment || attachment.type != MXKAttachmentTypeSticker)); + BOOL result = (event.eventType != MXEventTypePollStart && (!attachment || attachment.type != MXKAttachmentTypeSticker)); if (attachment && !BuildSettings.messageDetailsAllowCopyMedia) { - isCopyActionEnabled = NO; + result = NO; } - if (isCopyActionEnabled) + if (result) { switch (event.eventType) { case MXEventTypeRoomMessage: @@ -6047,7 +6214,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; if ([messageType isEqualToString:kMXMessageTypeKeyVerificationRequest]) { - isCopyActionEnabled = NO; + result = NO; } break; } @@ -6057,7 +6224,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; case MXEventTypeKeyVerificationMac: case MXEventTypeKeyVerificationDone: case MXEventTypeKeyVerificationCancel: - isCopyActionEnabled = NO; + result = NO; break; case MXEventTypeCustom: if ([event.type isEqualToString:kWidgetMatrixEventTypeString] @@ -6067,7 +6234,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; if ([widget.type isEqualToString:kWidgetTypeJitsiV1] || [widget.type isEqualToString:kWidgetTypeJitsiV2]) { - isCopyActionEnabled = NO; + result = NO; } } default: @@ -6075,60 +6242,60 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; } } - RoomContextualMenuItem *copyMenuItem = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionCopy]; - copyMenuItem.isEnabled = isCopyActionEnabled; - copyMenuItem.action = ^{ - MXStrongifyAndReturnIfNil(self); - - if (!attachment) - { - NSArray *components = roomBubbleTableViewCell.bubbleData.bubbleComponents; - MXKRoomBubbleComponent *selectedComponent; - for (selectedComponent in components) - { - if ([selectedComponent.event.eventId isEqualToString:event.eventId]) - { - break; - } - selectedComponent = nil; - } - NSString *textMessage = selectedComponent.textMessage; - - if (textMessage) - { - MXKPasteboardManager.shared.pasteboard.string = textMessage; - } - else - { - MXLogDebug(@"[RoomViewController] Contextual menu copy failed. Text is nil for room id/event id: %@/%@", selectedComponent.event.roomId, selectedComponent.event.eventId); - } - - [self hideContextualMenuAnimated:YES]; - } - else if (attachment.type != MXKAttachmentTypeSticker) - { - [self hideContextualMenuAnimated:YES completion:^{ - [self startActivityIndicator]; - - [attachment copy:^{ - - [self stopActivityIndicator]; - - } failure:^(NSError *error) { - - [self stopActivityIndicator]; - - //Alert user - [self showError:error]; - }]; - - // Start animation in case of download during attachment preparing - [roomBubbleTableViewCell startProgressUI]; - }]; - } - }; + return result; +} + +- (void)copyEvent:(MXEvent*)event inCell:(id)cell +{ + MXKRoomBubbleTableViewCell *roomBubbleTableViewCell = (MXKRoomBubbleTableViewCell *)cell; + MXKAttachment *attachment = roomBubbleTableViewCell.bubbleData.attachment; - return copyMenuItem; + if (!attachment) + { + NSArray *components = roomBubbleTableViewCell.bubbleData.bubbleComponents; + MXKRoomBubbleComponent *selectedComponent; + for (selectedComponent in components) + { + if ([selectedComponent.event.eventId isEqualToString:event.eventId]) + { + break; + } + selectedComponent = nil; + } + NSString *textMessage = selectedComponent.textMessage; + + if (textMessage) + { + MXKPasteboardManager.shared.pasteboard.string = textMessage; + } + else + { + MXLogDebug(@"[RoomViewController] Contextual menu copy failed. Text is nil for room id/event id: %@/%@", selectedComponent.event.roomId, selectedComponent.event.eventId); + } + + [self hideContextualMenuAnimated:YES]; + } + else if (attachment.type != MXKAttachmentTypeSticker) + { + [self hideContextualMenuAnimated:YES completion:^{ + [self startActivityIndicator]; + + [attachment copy:^{ + + [self stopActivityIndicator]; + + } failure:^(NSError *error) { + + [self stopActivityIndicator]; + + //Alert user + [self showError:error]; + }]; + + // Start animation in case of download during attachment preparing + [roomBubbleTableViewCell startProgressUI]; + }]; + } } - (RoomContextualMenuItem *)replyMenuItemWithEvent:(MXEvent*)event @@ -6150,6 +6317,23 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; return replyMenuItem; } +- (RoomContextualMenuItem *)replyInThreadMenuItemWithEvent:(MXEvent*)event +{ + MXWeakify(self); + + RoomContextualMenuItem *item = [[RoomContextualMenuItem alloc] initWithMenuAction:RoomContextualMenuActionReplyInThread]; + item.isEnabled = [self.roomDataSource canReplyToEventWithId:event.eventId] && !self.voiceMessageController.isRecordingAudio; + item.action = ^{ + MXStrongifyAndReturnIfNil(self); + + [self hideContextualMenuAnimated:YES cancelEventSelection:NO completion:nil]; + + [self openThreadWithId:event.eventId]; + }; + + return item; +} + - (RoomContextualMenuItem *)moreMenuItemWithEvent:(MXEvent*)event andCell:(id)cell { MXWeakify(self); @@ -6164,6 +6348,70 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; return moreMenuItem; } +#pragma mark - Threads + +- (void)openThreadWithId:(NSString *)threadId +{ + if (self.threadsBridgePresenter) + { + [self.threadsBridgePresenter dismissWithAnimated:YES completion:nil]; + self.threadsBridgePresenter = nil; + } + + self.threadsBridgePresenter = [[ThreadsCoordinatorBridgePresenter alloc] initWithSession:self.mainSession + roomId:self.roomDataSource.roomId + threadId:threadId]; + self.threadsBridgePresenter.delegate = self; + [self.threadsBridgePresenter pushFrom:self.navigationController animated:YES]; +} + +- (void)highlightAndDisplayEvent:(NSString *)eventId completion:(void (^)(void))completion +{ + NSInteger row = [self.roomDataSource indexOfCellDataWithEventId:eventId]; + if (row == NSNotFound) + { + // event with eventId is not loaded into data source yet, load another data source and display it + [self startActivityIndicator]; + MXWeakify(self); + [RoomDataSource loadRoomDataSourceWithRoomId:self.roomDataSource.roomId + initialEventId:eventId + threadId:nil + andMatrixSession:self.roomDataSource.mxSession + onComplete:^(RoomDataSource *roomDataSource) { + MXStrongifyAndReturnIfNil(self); + [self stopActivityIndicator]; + roomDataSource.markTimelineInitialEvent = YES; + [self displayRoom:roomDataSource]; + // Give the data source ownership to the room view controller. + self.hasRoomDataSourceOwnership = YES; + if (completion) + { + completion(); + } + }]; + return; + } + + self->customizedRoomDataSource.highlightedEventId = eventId; + + NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:0]; + if ([[self.bubblesTableView indexPathsForVisibleRows] containsObject:indexPath]) + { + [self.bubblesTableView reloadRowsAtIndexPaths:@[indexPath] + withRowAnimation:UITableViewRowAnimationAutomatic]; + } + else if ([self.bubblesTableView vc_hasIndexPath:indexPath]) + { + [self.bubblesTableView scrollToRowAtIndexPath:indexPath + atScrollPosition:UITableViewScrollPositionMiddle + animated:YES]; + } + if (completion) + { + completion(); + } +} + #pragma mark - RoomContextualMenuViewControllerDelegate - (void)roomContextualMenuViewControllerDidTapBackgroundOverlay:(RoomContextualMenuViewController *)viewController @@ -6563,4 +6811,29 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05; [self mention:member]; } +#pragma mark - ThreadsCoordinatorBridgePresenterDelegate + +- (void)threadsCoordinatorBridgePresenterDelegateDidComplete:(ThreadsCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + self.threadsBridgePresenter = nil; +} + +- (void)threadsCoordinatorBridgePresenterDelegateDidSelect:(ThreadsCoordinatorBridgePresenter *)coordinatorBridgePresenter roomId:(NSString *)roomId eventId:(NSString *)eventId +{ + MXWeakify(self); + [self.threadsBridgePresenter dismissWithAnimated:YES completion:^{ + MXStrongifyAndReturnIfNil(self); + + if (eventId) + { + [self highlightAndDisplayEvent:eventId completion:nil]; + } + }]; +} + +- (void)threadsCoordinatorBridgePresenterDidDismissInteractively:(ThreadsCoordinatorBridgePresenter *)coordinatorBridgePresenter +{ + self.threadsBridgePresenter = nil; +} + @end diff --git a/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.h b/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.h index a01848797..8494412e0 100644 --- a/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.h +++ b/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.h @@ -18,9 +18,4 @@ @interface RoomFilesSearchViewController : MXKSearchViewController -/** - The event selected in the search results - */ -@property (nonatomic, readonly) MXEvent *selectedEvent; - @end diff --git a/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.m b/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.m index c063cc7b9..b0c6c589a 100644 --- a/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.m +++ b/Riot/Modules/Room/Search/Files/RoomFilesSearchViewController.m @@ -170,18 +170,16 @@ { // Data in the cells are actually Vector RoomBubbleCellData FilesSearchCellData *cellData = (FilesSearchCellData*)[self.dataSource cellDataAtIndex:indexPath.row]; - _selectedEvent = cellData.searchResult.result; + MXEvent *event = cellData.searchResult.result; + + RoomSearchViewController *roomSearchViewController = (RoomSearchViewController*)self.parentViewController; // Hide the keyboard handled by the search text input which belongs to RoomSearchViewController - [((RoomSearchViewController*)self.parentViewController).searchBar resignFirstResponder]; + [roomSearchViewController resignFirstResponder]; [tableView deselectRowAtIndexPath:indexPath animated:YES]; - // Make the RoomSearchViewController (that contains this VC) open the RoomViewController - [self.parentViewController performSegueWithIdentifier:@"showTimeline" sender:self]; - - // Reset the selected event. RoomSearchViewController got it when here - _selectedEvent = nil; + [roomSearchViewController selectEvent:event]; } @end diff --git a/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.h b/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.h index b8d0d6d51..64fb26c97 100644 --- a/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.h +++ b/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.h @@ -20,9 +20,4 @@ @interface RoomMessagesSearchViewController : MXKSearchViewController -/** - The event selected in the search results - */ -@property (nonatomic, readonly) MXEvent *selectedEvent; - @end diff --git a/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.m b/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.m index 6848a1665..85a15e267 100644 --- a/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.m +++ b/Riot/Modules/Room/Search/Messages/RoomMessagesSearchViewController.m @@ -206,18 +206,16 @@ { // Data in the cells are actually Vector RoomBubbleCellData RoomBubbleCellData *cellData = (RoomBubbleCellData*)[self.dataSource cellDataAtIndex:indexPath.row]; - _selectedEvent = cellData.bubbleComponents[0].event; + MXEvent *event = cellData.bubbleComponents[0].event; + + RoomSearchViewController *roomSearchViewController = (RoomSearchViewController*)self.parentViewController; // Hide the keyboard handled by the search text input which belongs to RoomSearchViewController - [((RoomSearchViewController*)self.parentViewController).searchBar resignFirstResponder]; + [roomSearchViewController.searchBar resignFirstResponder]; [tableView deselectRowAtIndexPath:indexPath animated:YES]; - // Make the RoomSearchViewController (that contains this VC) open the RoomViewController - [self.parentViewController performSegueWithIdentifier:@"showTimeline" sender:self]; - - // Reset the selected event. RoomSearchViewController got it when here - _selectedEvent = nil; + [roomSearchViewController selectEvent:event]; } @end diff --git a/Riot/Modules/Room/Search/RoomSearchViewController.h b/Riot/Modules/Room/Search/RoomSearchViewController.h index 7d72226b5..bbf20aa57 100644 --- a/Riot/Modules/Room/Search/RoomSearchViewController.h +++ b/Riot/Modules/Room/Search/RoomSearchViewController.h @@ -27,4 +27,6 @@ + (instancetype)instantiate; +- (void)selectEvent:(MXEvent *)event; + @end diff --git a/Riot/Modules/Room/Search/RoomSearchViewController.m b/Riot/Modules/Room/Search/RoomSearchViewController.m index e6f576fe8..90c6bee63 100644 --- a/Riot/Modules/Room/Search/RoomSearchViewController.m +++ b/Riot/Modules/Room/Search/RoomSearchViewController.m @@ -152,6 +152,31 @@ return ThemeService.shared.theme.statusBarStyle; } +- (void)selectEvent:(MXEvent *)event +{ + ThreadParameters *threadParameters = nil; + if (event.threadId) + { + threadParameters = [[ThreadParameters alloc] initWithThreadId:event.threadId + stackRoomScreen:NO]; + } + else if ([self.mainSession.threadingService isEventThreadRoot:event]) + { + threadParameters = [[ThreadParameters alloc] initWithThreadId:event.eventId + stackRoomScreen:NO]; + } + + ScreenPresentationParameters *screenParameters = [[ScreenPresentationParameters alloc] initWithRestoreInitialDisplay:NO + stackAboveVisibleViews:YES]; + + RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:event.roomId + eventId:event.eventId + mxSession:self.mainSession + threadParameters:threadParameters + presentationParameters:screenParameters]; + [[LegacyAppDelegate theDelegate] showRoomWithParameters:parameters]; +} + #pragma mark - - (void)setRoomDataSource:(MXKRoomDataSource *)roomDataSource @@ -303,46 +328,6 @@ [self updateSearch]; } -#pragma mark - Navigation - -- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender -{ - [super prepareForSegue:segue sender:sender]; - - if ([[segue identifier] isEqualToString:@"showTimeline"]) - { - // Check whether an event has been selected from messages or files search tab - MXEvent *selectedSearchEvent = messagesSearchViewController.selectedEvent; - MXSession *selectedSearchEventSession = messagesSearchDataSource.mxSession; - if (!selectedSearchEvent) - { - selectedSearchEvent = filesSearchViewController.selectedEvent; - selectedSearchEventSession = filesSearchDataSource.mxSession; - } - - if (selectedSearchEvent) - { - RoomViewController *roomViewController = segue.destinationViewController; - - [RoomDataSource loadRoomDataSourceWithRoomId:selectedSearchEvent.roomId - initialEventId:selectedSearchEvent.eventId - andMatrixSession:selectedSearchEventSession onComplete:^(RoomDataSource *roomDataSource) { - - [roomDataSource finalizeInitialization]; - roomDataSource.markTimelineInitialEvent = YES; - - [roomViewController displayRoom:roomDataSource]; - roomViewController.hasRoomDataSourceOwnership = YES; - - roomViewController.navigationItem.leftItemsSupplementBackButton = YES; - }]; - } - - // Hide back button title - self.navigationItem.backBarButtonItem =[[UIBarButtonItem alloc] initWithTitle:@"" style:UIBarButtonItemStylePlain target:nil action:nil]; - } -} - #pragma mark - Search // Update search results under the currently selected tab diff --git a/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.h b/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.h index 410747d1d..b9fa3dd33 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.h +++ b/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.h @@ -168,7 +168,7 @@ extern NSString *const kMXKRoomBubbleCellUrlItemInteraction; @property (nonatomic) UIImage *picturePlaceholder; /** - The list of the temporary subviews that should be removed before reusing the cell (nil by default). + The list of the temporary subviews that should be removed before reusing the cell (empty array by default). */ @property (nonatomic) NSMutableArray *tmpSubviews; diff --git a/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.m b/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.m index b79ea8667..b337d2c0f 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.m +++ b/Riot/Modules/Room/Views/BubbleCells/Common/MXKRoomBubbleTableViewCell.m @@ -121,6 +121,7 @@ static BOOL _disableLongPressGestureOnEvent; self.readReceiptsAlignment = ReadReceiptAlignmentLeft; _allTextHighlighted = NO; _isAutoAnimatedGif = NO; + _tmpSubviews = [NSMutableArray array]; } - (void)awakeFromNib @@ -1005,14 +1006,11 @@ static BOOL _disableLongPressGestureOnEvent; self.bubbleInfoContainer.hidden = YES; // Remove temporary subviews - if (self.tmpSubviews) + for (UIView *view in self.tmpSubviews) { - for (UIView *view in self.tmpSubviews) - { - [view removeFromSuperview]; - } - self.tmpSubviews = nil; + [view removeFromSuperview]; } + [self.tmpSubviews removeAllObjects]; // Remove potential overlay subviews if (self.bubbleOverlayContainer) diff --git a/Riot/Modules/Room/Views/BubbleCells/RoomBubbleCellLayout.swift b/Riot/Modules/Room/Views/BubbleCells/RoomBubbleCellLayout.swift index b9d1db6e7..a860d942b 100644 --- a/Riot/Modules/Room/Views/BubbleCells/RoomBubbleCellLayout.swift +++ b/Riot/Modules/Room/Views/BubbleCells/RoomBubbleCellLayout.swift @@ -46,4 +46,8 @@ final class RoomBubbleCellLayout: NSObject { static let encryptedContentLeftMargin: CGFloat = 15.0 static let urlPreviewViewTopMargin: CGFloat = 8.0 + + // Threads + + static let threadSummaryViewTopMargin: CGFloat = 8.0 } diff --git a/Riot/Modules/Room/Views/BubbleCells/Styles/Plain/PlainRoomTimelineCellDecorator.swift b/Riot/Modules/Room/Views/BubbleCells/Styles/Plain/PlainRoomTimelineCellDecorator.swift index d684267e0..20b18a6a6 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Styles/Plain/PlainRoomTimelineCellDecorator.swift +++ b/Riot/Modules/Room/Views/BubbleCells/Styles/Plain/PlainRoomTimelineCellDecorator.swift @@ -142,6 +142,51 @@ class PlainRoomTimelineCellDecorator: RoomTimelineCellDecorator { } } + func addThreadSummaryView(_ threadSummaryView: ThreadSummaryView, + toCell cell: MXKRoomBubbleTableViewCell, + cellData: RoomBubbleCellData, + contentViewPositionY: CGFloat, + upperDecorationView: UIView?) { + + cell.addTemporarySubview(threadSummaryView) + + threadSummaryView.translatesAutoresizingMaskIntoConstraints = false + + let cellContentView = cell.contentView + + cellContentView.addSubview(threadSummaryView) + + var leftMargin = RoomBubbleCellLayout.reactionsViewLeftMargin + + if cellData.containsBubbleComponentWithEncryptionBadge { + leftMargin += RoomBubbleCellLayout.encryptedContentLeftMargin + } + + let rightMargin = RoomBubbleCellLayout.reactionsViewRightMargin + let topMargin = RoomBubbleCellLayout.threadSummaryViewTopMargin + let height = ThreadSummaryView.contentViewHeight(forThread: threadSummaryView.thread, + fitting: cellData.maxTextViewWidth) + + // The top constraint may need to include the URL preview view + let topConstraint: NSLayoutConstraint + if let upperDecorationView = upperDecorationView { + topConstraint = threadSummaryView.topAnchor.constraint(equalTo: upperDecorationView.bottomAnchor, + constant: topMargin) + } else { + topConstraint = threadSummaryView.topAnchor.constraint(equalTo: cellContentView.topAnchor, + constant: contentViewPositionY + topMargin) + } + + NSLayoutConstraint.activate([ + threadSummaryView.leadingAnchor.constraint(equalTo: cellContentView.leadingAnchor, + constant: leftMargin), + threadSummaryView.trailingAnchor.constraint(lessThanOrEqualTo: cellContentView.trailingAnchor, + constant: -rightMargin), + threadSummaryView.heightAnchor.constraint(equalToConstant: height), + topConstraint + ]) + } + func addSendStatusView(toCell cell: MXKRoomBubbleTableViewCell, withFailedEventIds failedEventIds: Set) { cell.updateTickView(withFailedEventIds: failedEventIds) } diff --git a/Riot/Modules/Room/Views/BubbleCells/Styles/RoomTimelineCellDecorator.swift b/Riot/Modules/Room/Views/BubbleCells/Styles/RoomTimelineCellDecorator.swift index 89a665fe8..ec60bcbf4 100644 --- a/Riot/Modules/Room/Views/BubbleCells/Styles/RoomTimelineCellDecorator.swift +++ b/Riot/Modules/Room/Views/BubbleCells/Styles/RoomTimelineCellDecorator.swift @@ -41,6 +41,12 @@ protocol RoomTimelineCellDecorator { cellData: RoomBubbleCellData, contentViewPositionY: CGFloat, upperDecorationView: UIView?) + + func addThreadSummaryView(_ threadSummaryView: ThreadSummaryView, + toCell cell: MXKRoomBubbleTableViewCell, + cellData: RoomBubbleCellData, + contentViewPositionY: CGFloat, + upperDecorationView: UIView?) func addSendStatusView(toCell cell: MXKRoomBubbleTableViewCell, withFailedEventIds failedEventIds: Set) diff --git a/Riot/Modules/Room/Views/Threads/ThreadSummaryModel.swift b/Riot/Modules/Room/Views/Threads/ThreadSummaryModel.swift new file mode 100644 index 000000000..585fc0203 --- /dev/null +++ b/Riot/Modules/Room/Views/Threads/ThreadSummaryModel.swift @@ -0,0 +1,23 @@ +// +// Copyright 2021 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 + +struct ThreadSummaryModel { + let numberOfReplies: Int + let lastMessageSenderAvatar: AvatarViewDataProtocol? + let lastMessageText: String? +} diff --git a/Riot/Modules/Room/Views/Threads/ThreadSummaryView.swift b/Riot/Modules/Room/Views/Threads/ThreadSummaryView.swift new file mode 100644 index 000000000..369188e58 --- /dev/null +++ b/Riot/Modules/Room/Views/Threads/ThreadSummaryView.swift @@ -0,0 +1,134 @@ +// +// Copyright 2021 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 Reusable + +@objc +protocol ThreadSummaryViewDelegate: AnyObject { + func threadSummaryViewTapped(_ summaryView: ThreadSummaryView) +} + +/// A view to display a summary for an `MXThread` generated by the `MXThreadingService`. +@objcMembers +class ThreadSummaryView: UIView { + + private enum Constants { + static let viewHeight: CGFloat = 32 + static let viewDefaultWidth: CGFloat = 320 + static let cornerRadius: CGFloat = 4 + } + + @IBOutlet private weak var iconView: UIImageView! + @IBOutlet private weak var numberOfRepliesLabel: UILabel! + @IBOutlet private weak var lastMessageAvatarView: UserAvatarView! + @IBOutlet private weak var lastMessageContentLabel: UILabel! + + private(set) var thread: MXThread? + + private lazy var tapGestureRecognizer: UITapGestureRecognizer = { + return UITapGestureRecognizer(target: self, action: #selector(tapped(_:))) + }() + + weak var delegate: ThreadSummaryViewDelegate? + + // MARK: - Setup + + init(withThread thread: MXThread) { + self.thread = thread + super.init(frame: CGRect(origin: .zero, + size: CGSize(width: Constants.viewDefaultWidth, + height: Constants.viewHeight))) + loadNibContent() + update(theme: ThemeService.shared().theme) + configure() + } + + static func contentViewHeight(forThread thread: MXThread?, fitting maxWidth: CGFloat) -> CGFloat { + return Constants.viewHeight + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + loadNibContent() + } + + @nonobjc func configure(withModel model: ThreadSummaryModel) { + numberOfRepliesLabel.text = String(model.numberOfReplies) + if let avatar = model.lastMessageSenderAvatar { + lastMessageAvatarView.fill(with: avatar) + } else { + lastMessageAvatarView.avatarImageView.image = nil + } + lastMessageContentLabel.text = model.lastMessageText + } + + private func configure() { + clipsToBounds = true + layer.cornerRadius = Constants.cornerRadius + addGestureRecognizer(tapGestureRecognizer) + + guard let thread = thread, + let lastMessage = thread.lastMessage, + let session = thread.session, + let eventFormatter = session.roomSummaryUpdateDelegate as? MXKEventFormatter, + let room = session.room(withRoomId: lastMessage.roomId) else { + lastMessageAvatarView.avatarImageView.image = nil + lastMessageContentLabel.text = nil + return + } + let lastMessageSender = session.user(withUserId: lastMessage.sender) + + let fallbackImage = AvatarFallbackImage.matrixItem(lastMessage.sender, + lastMessageSender?.displayname) + let avatarViewData = AvatarViewData(matrixItemId: lastMessage.sender, + displayName: lastMessageSender?.displayname, + avatarUrl: lastMessageSender?.avatarUrl, + mediaManager: session.mediaManager, + fallbackImage: fallbackImage) + + room.state { [weak self] roomState in + guard let self = self else { return } + let formatterError = UnsafeMutablePointer.allocate(capacity: 1) + let lastMessageText = eventFormatter.string(from: lastMessage, with: roomState, error: formatterError) + + let model = ThreadSummaryModel(numberOfReplies: thread.numberOfReplies, + lastMessageSenderAvatar: avatarViewData, + lastMessageText: lastMessageText) + self.configure(withModel: model) + } + } + + // MARK: - Action + + @objc + private func tapped(_ sender: UITapGestureRecognizer) { + guard thread != nil else { return } + delegate?.threadSummaryViewTapped(self) + } +} + +extension ThreadSummaryView: NibOwnerLoadable {} + +extension ThreadSummaryView: Themable { + + func update(theme: Theme) { + backgroundColor = theme.colors.system + numberOfRepliesLabel.textColor = theme.colors.secondaryContent + lastMessageContentLabel.textColor = theme.colors.secondaryContent + } + +} diff --git a/Riot/Modules/Room/Views/Threads/ThreadSummaryView.xib b/Riot/Modules/Room/Views/Threads/ThreadSummaryView.xib new file mode 100644 index 000000000..62e9a2d6c --- /dev/null +++ b/Riot/Modules/Room/Views/Threads/ThreadSummaryView.xib @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleModel.swift b/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleModel.swift new file mode 100644 index 000000000..059d99f5e --- /dev/null +++ b/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleModel.swift @@ -0,0 +1,27 @@ +// +// Copyright 2021 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 + +struct ThreadRoomTitleModel { + let roomAvatar: AvatarViewDataProtocol? + let roomEncryptionBadge: UIImage? + let roomDisplayName: String? + + static let empty = ThreadRoomTitleModel(roomAvatar: nil, + roomEncryptionBadge: nil, + roomDisplayName: nil) +} diff --git a/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleView.swift b/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleView.swift new file mode 100644 index 000000000..85340578e --- /dev/null +++ b/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleView.swift @@ -0,0 +1,149 @@ +// +// Copyright 2021 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 Reusable + +enum ThreadRoomTitleViewMode { + case allThreads + case specificThread(threadId: String) +} + +@objcMembers +class ThreadRoomTitleView: RoomTitleView { + + private enum Constants { + static let titleLeadingConstraintOnPortrait: CGFloat = 6 + static let titleLeadingConstraintOnLandscape: CGFloat = 18 + } + + var mode: ThreadRoomTitleViewMode = .allThreads { + didSet { + update() + } + } + + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var titleLabelLeadingConstraint: NSLayoutConstraint! + @IBOutlet private weak var roomAvatarView: RoomAvatarView! + @IBOutlet private weak var roomEncryptionBadgeView: UIImageView! + @IBOutlet private weak var roomNameLabel: UILabel! + + // MARK: - Methods + + func configure(withModel model: ThreadRoomTitleModel) { + if let avatarViewData = model.roomAvatar { + roomAvatarView.fill(with: avatarViewData) + } else { + roomAvatarView.avatarImageView.image = nil + } + roomEncryptionBadgeView.image = model.roomEncryptionBadge + roomEncryptionBadgeView.isHidden = model.roomEncryptionBadge == nil + roomNameLabel.text = model.roomDisplayName + } + + // MARK: - Overrides + + override var mxRoom: MXRoom! { + didSet { + update() + } + } + + override class func nib() -> UINib! { + return self.nib + } + + override func refreshDisplay() { + guard let room = mxRoom else { + // room not initialized yet + return + } + + let avatarViewData = AvatarViewData(matrixItemId: room.matrixItemId, + displayName: room.displayName, + avatarUrl: room.mxContentUri, + mediaManager: room.mxSession.mediaManager, + fallbackImage: AvatarFallbackImage.matrixItem(room.matrixItemId, + room.displayName)) + + let encrpytionBadge: UIImage? + if let summary = room.summary, room.mxSession.crypto != nil { + encrpytionBadge = EncryptionTrustLevelBadgeImageHelper.roomBadgeImage(for: summary.roomEncryptionTrustLevel()) + } else { + encrpytionBadge = nil + } + + let model = ThreadRoomTitleModel(roomAvatar: avatarViewData, + roomEncryptionBadge: encrpytionBadge, + roomDisplayName: room.displayName) + configure(withModel: model) + } + + override func awakeFromNib() { + super.awakeFromNib() + + update(theme: ThemeService.shared().theme) + registerThemeServiceDidChangeThemeNotification() + } + + override func updateLayout(for orientation: UIInterfaceOrientation) { + super.updateLayout(for: orientation) + + if orientation.isPortrait { + titleLabelLeadingConstraint.constant = Constants.titleLeadingConstraintOnPortrait + } else { + titleLabelLeadingConstraint.constant = Constants.titleLeadingConstraintOnLandscape + } + } + + // MARK: - Private + + private func registerThemeServiceDidChangeThemeNotification() { + NotificationCenter.default.addObserver(self, + selector: #selector(themeDidChange), + name: .themeServiceDidChangeTheme, + object: nil) + } + + private func update() { + switch mode { + case .allThreads: + titleLabel.text = VectorL10n.threadsTitle + case .specificThread: + titleLabel.text = VectorL10n.roomThreadTitle + } + } + + // MARK: - Actions + + @objc private func themeDidChange() { + self.update(theme: ThemeService.shared().theme) + } + +} + +extension ThreadRoomTitleView: NibLoadable {} + +extension ThreadRoomTitleView: Themable { + + func update(theme: Theme) { + roomAvatarView.backgroundColor = .clear + titleLabel.textColor = theme.colors.primaryContent + roomNameLabel.textColor = theme.colors.secondaryContent + } + +} diff --git a/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleView.xib b/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleView.xib new file mode 100644 index 000000000..3cab9f1a2 --- /dev/null +++ b/Riot/Modules/Room/Views/Title/Thread/ThreadRoomTitleView.xib @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m index 2e46a6a91..be9326fdb 100644 --- a/Riot/Modules/Settings/SettingsViewController.m +++ b/Riot/Modules/Settings/SettingsViewController.m @@ -163,7 +163,8 @@ typedef NS_ENUM(NSUInteger, ABOUT) typedef NS_ENUM(NSUInteger, LABS_ENABLE) { - LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX + LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0, + LABS_ENABLE_THREADS_INDEX }; typedef NS_ENUM(NSUInteger, SECURITY) @@ -578,7 +579,7 @@ TableViewSectionsDelegate> { Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS]; [sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX]; - + [sectionLabs addRowWithTag:LABS_ENABLE_THREADS_INDEX]; sectionLabs.headerTitle = [VectorL10n settingsLabs]; if (sectionLabs.hasAnyRows) { @@ -2460,6 +2461,18 @@ TableViewSectionsDelegate> [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableRingingForGroupCalls:) forControlEvents:UIControlEventValueChanged]; + cell = labelAndSwitchCell; + } + else if (row == LABS_ENABLE_THREADS_INDEX) + { + MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath]; + + labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnableThreads]; + labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableThreads; + labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor; + + [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableThreads:) forControlEvents:UIControlEventValueChanged]; + cell = labelAndSwitchCell; } } @@ -3194,6 +3207,13 @@ TableViewSectionsDelegate> RiotSettings.shared.enableRingingForGroupCalls = sender.isOn; } +- (void)toggleEnableThreads:(UISwitch *)sender +{ + RiotSettings.shared.enableThreads = sender.isOn; + [[MXKRoomDataSourceManager sharedManagerForMatrixSession:self.mainSession] reset]; + [[AppDelegate theDelegate] restoreEmptyDetailsViewController]; +} + - (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender { RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn; diff --git a/Riot/Modules/SplitView/SplitViewCoordinator.swift b/Riot/Modules/SplitView/SplitViewCoordinator.swift index ee1ad5af7..442da486e 100644 --- a/Riot/Modules/SplitView/SplitViewCoordinator.swift +++ b/Riot/Modules/SplitView/SplitViewCoordinator.swift @@ -363,6 +363,30 @@ extension SplitViewCoordinator: SplitViewMasterPresentableDelegate { detailNavigationRouter.push(detailPresentable, animated: true, popCompletion: popCompletion) } + func splitViewMasterPresentable(_ presentable: Presentable, wantsToReplaceDetailsWith modules: [NavigationModule]) { + MXLog.debug("[SplitViewCoordinator] splitViewMasterPresentable: \(presentable) wantsToReplaceDetailsWith modules: \(modules)") + + self.detailNavigationRouter?.setModules(modules, animated: true) + } + + func splitViewMasterPresentable(_ presentable: Presentable, wantsToStack modules: [NavigationModule]) { + guard let detailNavigationRouter = self.detailNavigationRouter else { + MXLog.warning("[SplitViewCoordinator] Failed to stack \(modules) because detailNavigationRouter is nil") + return + } + + detailNavigationRouter.push(modules, animated: true) + } + + func splitViewMasterPresentable(_ presentable: Presentable, wantsToPopTo module: Presentable) { + guard let detailNavigationRouter = self.detailNavigationRouter else { + MXLog.warning("[SplitViewCoordinator] Failed to pop to \(module) because detailNavigationRouter is nil") + return + } + + detailNavigationRouter.popToModule(module, animated: true) + } + func splitViewMasterPresentableWantsToResetDetail(_ presentable: Presentable) { self.resetDetails(animated: false) } diff --git a/Riot/Modules/SplitView/SplitViewPresentable.swift b/Riot/Modules/SplitView/SplitViewPresentable.swift index 7932a4815..4e63663f1 100644 --- a/Riot/Modules/SplitView/SplitViewPresentable.swift +++ b/Riot/Modules/SplitView/SplitViewPresentable.swift @@ -27,6 +27,15 @@ protocol SplitViewMasterPresentableDelegate: AnyObject { /// Stack the detailPresentable on the existing split view detail stack func splitViewMasterPresentable(_ presentable: Presentable, wantsToStack detailPresentable: Presentable, popCompletion: (() -> Void)?) + /// Replace split view detail with the given modules + func splitViewMasterPresentable(_ presentable: Presentable, wantsToReplaceDetailsWith modules: [NavigationModule]) + + /// Stack modules on the existing split view detail stack + func splitViewMasterPresentable(_ presentable: Presentable, wantsToStack modules: [NavigationModule]) + + /// Pop to module on the existing split view detail stack + func splitViewMasterPresentable(_ presentable: Presentable, wantsToPopTo module: Presentable) + /// Reset detail stack with placeholder func splitViewMasterPresentableWantsToResetDetail(_ presentable: Presentable) } diff --git a/Riot/Modules/TabBar/TabBarCoordinator.swift b/Riot/Modules/TabBar/TabBarCoordinator.swift index 37342d973..f9d27589d 100644 --- a/Riot/Modules/TabBar/TabBarCoordinator.swift +++ b/Riot/Modules/TabBar/TabBarCoordinator.swift @@ -26,6 +26,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { // MARK: Private private let parameters: TabBarCoordinatorParameters + private let activityIndicatorPresenter: ActivityIndicatorPresenterType // Indicate if the Coordinator has started once private var hasStartedOnce: Bool { @@ -69,6 +70,7 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { let masterNavigationController = RiotNavigationController() self.navigationRouter = NavigationRouter(navigationController: masterNavigationController) self.masterNavigationController = masterNavigationController + self.activityIndicatorPresenter = ActivityIndicatorPresenter() } // MARK: - Public methods @@ -381,25 +383,31 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { } } - private func showRoom(withId roomId: String) { + private func showRoom(withId roomId: String, eventId: String? = nil) { guard let matrixSession = self.parameters.userSessionsService.mainUserSession?.matrixSession else { return } - self.showRoom(with: roomId, eventId: nil, matrixSession: matrixSession) + self.showRoom(with: roomId, eventId: eventId, matrixSession: matrixSession) } private func showRoom(withNavigationParameters roomNavigationParameters: RoomNavigationParameters, completion: (() -> Void)?) { - let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, - session: roomNavigationParameters.mxSession, - roomId: roomNavigationParameters.roomId, - eventId: roomNavigationParameters.eventId) - - self.showRoom(with: roomCoordinatorParameters, - stackOnSplitViewDetail: roomNavigationParameters.presentationParameters.stackAboveVisibleViews, - completion: completion) + if let threadParameters = roomNavigationParameters.threadParameters, threadParameters.stackRoomScreen { + showRoomAndThread(with: roomNavigationParameters, + completion: completion) + } else { + let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, + session: roomNavigationParameters.mxSession, + roomId: roomNavigationParameters.roomId, + eventId: roomNavigationParameters.eventId, + threadId: roomNavigationParameters.threadParameters?.threadId) + + self.showRoom(with: roomCoordinatorParameters, + stackOnSplitViewDetail: roomNavigationParameters.presentationParameters.stackAboveVisibleViews, + completion: completion) + } } private func showRoom(with roomId: String, eventId: String?, matrixSession: MXSession, completion: (() -> Void)? = nil) { @@ -436,18 +444,22 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { stackOnSplitViewDetail: Bool = false, completion: (() -> Void)? = nil) { - if let topRoomCoordinator = self.splitViewMasterPresentableDelegate?.detailModules.last as? RoomCoordinatorProtocol, - parameters.roomId == topRoomCoordinator.roomId && parameters.session == topRoomCoordinator.mxSession { - - // RoomCoordinator with the same room id and Matrix session is shown - - if let eventId = parameters.eventId { - // If there is an event id ask the RoomCoordinator to start with this one - topRoomCoordinator.start(withEventId: eventId, completion: completion) - } else { - // If there is no event id defined do nothing - completion?() - } + // try to find the desired room screen in the stack + if let roomCoordinator = self.splitViewMasterPresentableDelegate?.detailModules.last(where: { presentable in + guard let roomCoordinator = presentable as? RoomCoordinatorProtocol else { + return false + } + return roomCoordinator.roomId == parameters.roomId + && roomCoordinator.threadId == parameters.threadId + && roomCoordinator.mxSession == parameters.session + }) as? RoomCoordinatorProtocol { + self.splitViewMasterPresentableDelegate?.splitViewMasterPresentable(self, wantsToPopTo: roomCoordinator) + // go to a specific event if provided + if let eventId = parameters.eventId { + roomCoordinator.start(withEventId: eventId, completion: completion) + } else { + completion?() + } return } @@ -461,6 +473,63 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { self?.remove(childCoordinator: coordinator) } } + + private func showRoomAndThread(with roomNavigationParameters: RoomNavigationParameters, + completion: (() -> Void)? = nil) { + self.activityIndicatorPresenter.presentActivityIndicator(on: toPresentable().view, animated: false) + let dispatchGroup = DispatchGroup() + + // create room coordinator + let roomCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, + session: roomNavigationParameters.mxSession, + roomId: roomNavigationParameters.roomId, + eventId: nil, + threadId: nil) + + dispatchGroup.enter() + let roomCoordinator = RoomCoordinator(parameters: roomCoordinatorParameters) + roomCoordinator.delegate = self + roomCoordinator.start { + dispatchGroup.leave() + } + self.add(childCoordinator: roomCoordinator) + + // create thread coordinator + let threadCoordinatorParameters = RoomCoordinatorParameters(navigationRouterStore: NavigationRouterStore.shared, + session: roomNavigationParameters.mxSession, + roomId: roomNavigationParameters.roomId, + eventId: roomNavigationParameters.eventId, + threadId: roomNavigationParameters.threadParameters?.threadId) + + dispatchGroup.enter() + let threadCoordinator = RoomCoordinator(parameters: threadCoordinatorParameters) + threadCoordinator.delegate = self + threadCoordinator.start { + dispatchGroup.leave() + } + self.add(childCoordinator: threadCoordinator) + + dispatchGroup.notify(queue: .main) { [weak self] in + guard let self = self else { return } + let modules: [NavigationModule] = [ + NavigationModule(presentable: roomCoordinator, popCompletion: { [weak self] in + // NOTE: The RoomDataSource releasing is handled in SplitViewCoordinator + self?.remove(childCoordinator: roomCoordinator) + }), + NavigationModule(presentable: threadCoordinator, popCompletion: { [weak self] in + // NOTE: The RoomDataSource releasing is handled in SplitViewCoordinator + self?.remove(childCoordinator: threadCoordinator) + }) + ] + + self.showSplitViewDetails(with: modules, + stack: roomNavigationParameters.presentationParameters.stackAboveVisibleViews) + + self.activityIndicatorPresenter.removeCurrentActivityIndicator(animated: true) + + completion?() + } + } // MARK: Split view @@ -483,6 +552,14 @@ final class TabBarCoordinator: NSObject, TabBarCoordinatorType { } } + private func showSplitViewDetails(with modules: [NavigationModule], stack: Bool) { + if stack { + self.splitViewMasterPresentableDelegate?.splitViewMasterPresentable(self, wantsToStack: modules) + } else { + self.splitViewMasterPresentableDelegate?.splitViewMasterPresentable(self, wantsToReplaceDetailsWith: modules) + } + } + private func resetSplitViewDetails() { self.splitViewMasterPresentableDelegate?.splitViewMasterPresentableWantsToResetDetail(self) } @@ -620,9 +697,10 @@ extension TabBarCoordinator: RoomCoordinatorDelegate { self.navigationRouter.popModule(animated: true) } - func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String) { - self.showRoom(withId: roomId) + func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String, eventId: String?) { + self.showRoom(withId: roomId, eventId: eventId) } + } // MARK: - UIGestureRecognizerDelegate diff --git a/Riot/Modules/Threads/Thread/ThreadViewController.swift b/Riot/Modules/Threads/Thread/ThreadViewController.swift new file mode 100644 index 000000000..879a6dd3a --- /dev/null +++ b/Riot/Modules/Threads/Thread/ThreadViewController.swift @@ -0,0 +1,118 @@ +// +// Copyright 2021 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 + +class ThreadViewController: RoomViewController { + + // MARK: Private + + private(set) var threadId: String! + + private var permalink: String? { + guard let threadId = threadId else { return nil } + return MXTools.permalink(toEvent: threadId, inRoom: roomDataSource.roomId) + } + + class func instantiate(withThreadId threadId: String, + configuration: RoomDisplayConfiguration) -> ThreadViewController { + let threadVC = ThreadViewController.instantiate(with: configuration) + threadVC.threadId = threadId + return threadVC + } + + override class func nib() -> UINib! { + // reuse 'RoomViewController.xib' file as the nib + return UINib(nibName: String(describing: RoomViewController.self), bundle: .main) + } + + override func setRoomTitleViewClass(_ roomTitleViewClass: AnyClass!) { + super.setRoomTitleViewClass(ThreadRoomTitleView.self) + + guard let threadTitleView = self.titleView as? ThreadRoomTitleView else { + return + } + + threadTitleView.mode = .specificThread(threadId: threadId) + } + + override func onButtonPressed(_ sender: Any) { + if let sender = sender as? UIBarButtonItem, sender == navigationItem.rightBarButtonItem { + showThreadActions() + return + } + super.onButtonPressed(sender) + } + + private func showThreadActions() { + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + let viewInRoomAction = UIAlertAction(title: VectorL10n.roomEventActionViewInRoom, + style: .default, + handler: { [weak self] action in + guard let self = self else { return } + self.delegate?.roomViewController(self, + showRoomWithId: self.roomDataSource.roomId, + eventId: self.threadId) + }) + alertController.addAction(viewInRoomAction) + + let copyLinkAction = UIAlertAction(title: VectorL10n.threadCopyLinkToThread, + style: .default, + handler: { [weak self] action in + guard let self = self else { return } + self.copyPermalink() + }) + alertController.addAction(copyLinkAction) + + let shareAction = UIAlertAction(title: VectorL10n.roomEventActionShare, + style: .default, + handler: { [weak self] action in + guard let self = self else { return } + self.sharePermalink() + }) + alertController.addAction(shareAction) + + alertController.addAction(UIAlertAction(title: VectorL10n.cancel, + style: .cancel, + handler: nil)) + + alertController.popoverPresentationController?.barButtonItem = navigationItem.rightBarButtonItem + + self.present(alertController, animated: true, completion: nil) + } + + private func copyPermalink() { + guard let permalink = permalink else { + return + } + + MXKPasteboardManager.shared.pasteboard.string = permalink + } + + private func sharePermalink() { + guard let permalink = permalink else { + return + } + + let activityVC = UIActivityViewController(activityItems: [permalink], + applicationActivities: nil) + activityVC.modalTransitionStyle = .coverVertical + activityVC.popoverPresentationController?.barButtonItem = navigationItem.rightBarButtonItem + present(activityVC, animated: true, completion: nil) + } + +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListCoordinator.swift b/Riot/Modules/Threads/ThreadList/ThreadListCoordinator.swift new file mode 100644 index 000000000..1dabc738a --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListCoordinator.swift @@ -0,0 +1,75 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +final class ThreadListCoordinator: ThreadListCoordinatorProtocol { + + // MARK: - Properties + + // MARK: Private + + private let parameters: ThreadListCoordinatorParameters + private var threadListViewModel: ThreadListViewModelProtocol + private let threadListViewController: ThreadListViewController + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: ThreadListCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: ThreadListCoordinatorParameters) { + self.parameters = parameters + let threadListViewModel = ThreadListViewModel(session: self.parameters.session, + roomId: self.parameters.roomId) + let threadListViewController = ThreadListViewController.instantiate(with: threadListViewModel) + self.threadListViewModel = threadListViewModel + self.threadListViewController = threadListViewController + } + + // MARK: - Public + + func start() { + self.threadListViewModel.coordinatorDelegate = self + } + + func toPresentable() -> UIViewController { + return self.threadListViewController + } +} + +// MARK: - ThreadListViewModelCoordinatorDelegate +extension ThreadListCoordinator: ThreadListViewModelCoordinatorDelegate { + + func threadListViewModelDidLoadThreads(_ viewModel: ThreadListViewModelProtocol) { + self.delegate?.threadListCoordinatorDidLoadThreads(self) + } + + func threadListViewModelDidSelectThread(_ viewModel: ThreadListViewModelProtocol, thread: MXThread) { + self.delegate?.threadListCoordinatorDidSelectThread(self, thread: thread) + } + + func threadListViewModelDidCancel(_ viewModel: ThreadListViewModelProtocol) { + self.delegate?.threadListCoordinatorDidCancel(self) + } +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListCoordinatorParameters.swift b/Riot/Modules/Threads/ThreadList/ThreadListCoordinatorParameters.swift new file mode 100644 index 000000000..74c6b9885 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListCoordinatorParameters.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +/// ThreadListCoordinator input parameters +struct ThreadListCoordinatorParameters { + + /// The Matrix session + let session: MXSession + + /// Room identifier + let roomId: String +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListCoordinatorProtocol.swift b/Riot/Modules/Threads/ThreadList/ThreadListCoordinatorProtocol.swift new file mode 100644 index 000000000..d88e50581 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListCoordinatorProtocol.swift @@ -0,0 +1,30 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +protocol ThreadListCoordinatorDelegate: AnyObject { + func threadListCoordinatorDidLoadThreads(_ coordinator: ThreadListCoordinatorProtocol) + func threadListCoordinatorDidSelectThread(_ coordinator: ThreadListCoordinatorProtocol, thread: MXThread) + func threadListCoordinatorDidCancel(_ coordinator: ThreadListCoordinatorProtocol) +} + +/// `ThreadListCoordinatorProtocol` is a protocol describing a Coordinator that handle thread list navigation flow. +protocol ThreadListCoordinatorProtocol: Coordinator, Presentable { + var delegate: ThreadListCoordinatorDelegate? { get } +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewAction.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewAction.swift new file mode 100644 index 000000000..629e430e8 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewAction.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +/// ThreadListViewController view actions exposed to view model +enum ThreadListViewAction { + case loadData + case complete + case showFilterTypes + case selectFilterType(_ type: ThreadListFilterType) + case selectThread(_ index: Int) + case cancel +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewController.storyboard b/Riot/Modules/Threads/ThreadList/ThreadListViewController.storyboard new file mode 100644 index 000000000..ddfb9060c --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewController.storyboard @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift new file mode 100644 index 000000000..9c2a75a9f --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewController.swift @@ -0,0 +1,282 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +final class ThreadListViewController: UIViewController { + + // MARK: - Properties + + // MARK: Outlets + + @IBOutlet private weak var threadsTableView: UITableView! + @IBOutlet private weak var emptyView: ThreadListEmptyView! + + // MARK: Private + + private var viewModel: ThreadListViewModelProtocol! + private var theme: Theme! + private var keyboardAvoider: KeyboardAvoider? + private var errorPresenter: MXKErrorPresentation! + private var activityPresenter: ActivityIndicatorPresenter! + private var titleView: ThreadRoomTitleView! + + // MARK: - Setup + + class func instantiate(with viewModel: ThreadListViewModelProtocol) -> ThreadListViewController { + let viewController = StoryboardScene.ThreadListViewController.initialScene.instantiate() + viewController.viewModel = viewModel + viewController.theme = ThemeService.shared().theme + return viewController + } + + // MARK: - Life cycle + + override func viewDidLoad() { + super.viewDidLoad() + + // Do any additional setup after loading the view. + + self.setupViews() + self.keyboardAvoider = KeyboardAvoider(scrollViewContainerView: self.view, scrollView: self.threadsTableView) + self.activityPresenter = ActivityIndicatorPresenter() + self.errorPresenter = MXKErrorAlertPresentation() + + self.registerThemeServiceDidChangeThemeNotification() + self.update(theme: self.theme) + + self.viewModel.viewDelegate = self + + self.viewModel.process(viewAction: .loadData) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.keyboardAvoider?.startAvoiding() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.keyboardAvoider?.stopAvoiding() + } + + override var preferredStatusBarStyle: UIStatusBarStyle { + return self.theme.statusBarStyle + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + guard let titleView = self.titleView else { return } + if UIApplication.shared.statusBarOrientation.isPortrait { + titleView.updateLayout(for: .landscapeLeft) + } else { + titleView.updateLayout(for: .portrait) + } + } + + // MARK: - Private + + private func update(theme: Theme) { + self.theme = theme + + self.view.backgroundColor = theme.headerBackgroundColor + + if let navigationBar = self.navigationController?.navigationBar { + theme.applyStyle(onNavigationBar: navigationBar) + } + + emptyView.update(theme: theme) + emptyView.backgroundColor = theme.colors.background + self.threadsTableView.backgroundColor = theme.backgroundColor + self.threadsTableView.separatorColor = theme.colors.separator + self.threadsTableView.reloadData() + } + + private func registerThemeServiceDidChangeThemeNotification() { + NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil) + } + + @objc private func themeDidChange() { + self.update(theme: ThemeService.shared().theme) + } + + private func setupViews() { + let titleView = ThreadRoomTitleView.loadFromNib() + titleView.mode = .allThreads + titleView.configure(withModel: viewModel.titleModel) + titleView.updateLayout(for: UIApplication.shared.statusBarOrientation) + self.titleView = titleView + navigationItem.leftItemsSupplementBackButton = true + vc_removeBackTitle() + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: titleView) + + navigationItem.rightBarButtonItem = UIBarButtonItem(image: Asset.Images.threadsFilter.image, + style: .plain, + target: self, + action: #selector(filterButtonTapped(_:))) + + self.threadsTableView.tableFooterView = UIView() + self.threadsTableView.register(cellType: ThreadTableViewCell.self) + self.threadsTableView.keyboardDismissMode = .interactive + } + + private func render(viewState: ThreadListViewState) { + switch viewState { + case .idle: + break + case .loading: + self.renderLoading() + case .loaded: + self.renderLoaded() + case .empty(let model): + self.renderEmptyView(withModel: model) + case .showingFilterTypes: + self.renderShowingFilterTypes() + case .error(let error): + self.render(error: error) + } + } + + private func renderLoading() { + emptyView.isHidden = true + threadsTableView.isHidden = true + self.activityPresenter.presentActivityIndicator(on: self.view, animated: true) + } + + private func renderLoaded() { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + threadsTableView.isHidden = false + self.threadsTableView.reloadData() + navigationItem.rightBarButtonItem?.isEnabled = true + } + + private func renderEmptyView(withModel model: ThreadListEmptyModel) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + emptyView.configure(withModel: model) + threadsTableView.isHidden = true + emptyView.isHidden = false + navigationItem.rightBarButtonItem?.isEnabled = viewModel.selectedFilterType == .myThreads + } + + private func renderShowingFilterTypes() { + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + let allThreadsAction = UIAlertAction(title: ThreadListFilterType.all.title, + style: .default, + handler: { [weak self] action in + guard let self = self else { return } + self.viewModel.process(viewAction: .selectFilterType(.all)) + }) + if self.viewModel.selectedFilterType == .all { + allThreadsAction.setValue(true, forKey: "checked") + } + alertController.addAction(allThreadsAction) + + let myThreadsAction = UIAlertAction(title: ThreadListFilterType.myThreads.title, + style: .default, + handler: { [weak self] action in + guard let self = self else { return } + self.viewModel.process(viewAction: .selectFilterType(.myThreads)) + }) + if self.viewModel.selectedFilterType == .myThreads { + myThreadsAction.setValue(true, forKey: "checked") + } + alertController.addAction(myThreadsAction) + + alertController.addAction(UIAlertAction(title: VectorL10n.cancel, + style: .cancel, + handler: nil)) + + alertController.popoverPresentationController?.barButtonItem = navigationItem.rightBarButtonItem + + self.present(alertController, animated: true, completion: nil) + } + + private func render(error: Error) { + self.activityPresenter.removeCurrentActivityIndicator(animated: true) + self.errorPresenter.presentError(from: self, forError: error, animated: true, handler: nil) + } + + // MARK: - Actions + + @objc + private func filterButtonTapped(_ sender: UIBarButtonItem) { + self.viewModel.process(viewAction: .showFilterTypes) + } + +} + +// MARK: - ThreadListViewModelViewDelegate + +extension ThreadListViewController: ThreadListViewModelViewDelegate { + + func threadListViewModel(_ viewModel: ThreadListViewModelProtocol, + didUpdateViewState viewSate: ThreadListViewState) { + self.render(viewState: viewSate) + } +} + +// MARK: - UITableViewDataSource + +extension ThreadListViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.numberOfThreads + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: ThreadTableViewCell = tableView.dequeueReusableCell(for: indexPath) + + if let threadModel = viewModel.threadModel(at: indexPath.row) { + cell.configure(withModel: threadModel) + } + cell.update(theme: theme) + + return cell + } + +} + +// MARK: - UITableViewDelegate + +extension ThreadListViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cell.backgroundColor = theme.backgroundColor + cell.selectedBackgroundView = UIView() + cell.selectedBackgroundView?.backgroundColor = theme.selectedBackgroundColor + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + viewModel.process(viewAction: .selectThread(indexPath.row)) + } + +} + +// MARK: - ThreadListEmptyViewDelegate + +extension ThreadListViewController: ThreadListEmptyViewDelegate { + + func threadListEmptyViewTappedShowAllThreads(_ emptyView: ThreadListEmptyView) { + viewModel.process(viewAction: .selectFilterType(.all)) + } + +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift new file mode 100644 index 000000000..77bb98487 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewModel.swift @@ -0,0 +1,277 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +final class ThreadListViewModel: ThreadListViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let roomId: String + private var threads: [MXThread] = [] + private var eventFormatter: MXKEventFormatter? + private var roomState: MXRoomState? + + private var currentOperation: MXHTTPOperation? + + // MARK: Public + + weak var viewDelegate: ThreadListViewModelViewDelegate? + weak var coordinatorDelegate: ThreadListViewModelCoordinatorDelegate? + var selectedFilterType: ThreadListFilterType = .all + + private(set) var viewState: ThreadListViewState = .idle { + didSet { + self.viewDelegate?.threadListViewModel(self, didUpdateViewState: viewState) + } + } + + // MARK: - Setup + + init(session: MXSession, + roomId: String) { + self.session = session + self.roomId = roomId + session.threadingService.addDelegate(self) + } + + deinit { + session.threadingService.removeDelegate(self) + self.cancelOperations() + } + + // MARK: - Public + + func process(viewAction: ThreadListViewAction) { + switch viewAction { + case .loadData: + loadData() + case .complete: + coordinatorDelegate?.threadListViewModelDidLoadThreads(self) + case .showFilterTypes: + viewState = .showingFilterTypes + case .selectFilterType(let type): + selectedFilterType = type + loadData() + case .selectThread(let index): + selectThread(index) + case .cancel: + cancelOperations() + coordinatorDelegate?.threadListViewModelDidCancel(self) + } + } + + var numberOfThreads: Int { + return threads.count + } + + func threadModel(at index: Int) -> ThreadModel? { + guard index < threads.count else { + return nil + } + return model(forThread: threads[index]) + } + + var titleModel: ThreadRoomTitleModel { + guard let room = session.room(withRoomId: roomId) else { + return .empty + } + + let avatarViewData = AvatarViewData(matrixItemId: room.matrixItemId, + displayName: room.displayName, + avatarUrl: room.mxContentUri, + mediaManager: room.mxSession.mediaManager, + fallbackImage: AvatarFallbackImage.matrixItem(room.matrixItemId, + room.displayName)) + + let encrpytionBadge: UIImage? + if let summary = room.summary, session.crypto != nil { + encrpytionBadge = EncryptionTrustLevelBadgeImageHelper.roomBadgeImage(for: summary.roomEncryptionTrustLevel()) + } else { + encrpytionBadge = nil + } + + return ThreadRoomTitleModel(roomAvatar: avatarViewData, + roomEncryptionBadge: encrpytionBadge, + roomDisplayName: room.displayName) + } + + private var emptyViewModel: ThreadListEmptyModel { + switch selectedFilterType { + case .all: + return ThreadListEmptyModel(icon: Asset.Images.roomContextMenuReplyInThread.image, + title: VectorL10n.threadsEmptyTitle, + info: VectorL10n.threadsEmptyInfoAll, + tip: VectorL10n.threadsEmptyTip, + showAllThreadsButtonTitle: VectorL10n.threadsEmptyShowAllThreads, + showAllThreadsButtonHidden: true) + case .myThreads: + return ThreadListEmptyModel(icon: Asset.Images.roomContextMenuReplyInThread.image, + title: VectorL10n.threadsEmptyTitle, + info: VectorL10n.threadsEmptyInfoMy, + tip: VectorL10n.threadsEmptyTip, + showAllThreadsButtonTitle: VectorL10n.threadsEmptyShowAllThreads, + showAllThreadsButtonHidden: false) + } + } + + // MARK: - Private + + private func model(forThread thread: MXThread) -> ThreadModel { + let rootAvatarViewData: AvatarViewData? + let rootMessageSender: MXUser? + let lastAvatarViewData: AvatarViewData? + let lastMessageSender: MXUser? + let rootMessageText = rootMessageText(forThread: thread) + let (lastMessageText, lastMessageTime) = lastMessageTextAndTime(forThread: thread) + + // root message + if let rootMessage = thread.rootMessage, let senderId = rootMessage.sender { + rootMessageSender = session.user(withUserId: rootMessage.sender) + + let fallbackImage = AvatarFallbackImage.matrixItem(senderId, + rootMessageSender?.displayname) + rootAvatarViewData = AvatarViewData(matrixItemId: senderId, + displayName: rootMessageSender?.displayname, + avatarUrl: rootMessageSender?.avatarUrl, + mediaManager: session.mediaManager, + fallbackImage: fallbackImage) + } else { + rootAvatarViewData = nil + rootMessageSender = nil + } + + // last message + if let lastMessage = thread.lastMessage, let senderId = lastMessage.sender { + lastMessageSender = session.user(withUserId: lastMessage.sender) + + let fallbackImage = AvatarFallbackImage.matrixItem(senderId, + lastMessageSender?.displayname) + lastAvatarViewData = AvatarViewData(matrixItemId: senderId, + displayName: lastMessageSender?.displayname, + avatarUrl: lastMessageSender?.avatarUrl, + mediaManager: session.mediaManager, + fallbackImage: fallbackImage) + } else { + lastAvatarViewData = nil + lastMessageSender = nil + } + + let summaryModel = ThreadSummaryModel(numberOfReplies: thread.numberOfReplies, + lastMessageSenderAvatar: lastAvatarViewData, + lastMessageText: lastMessageText) + + return ThreadModel(rootMessageSenderAvatar: rootAvatarViewData, + rootMessageSenderDisplayName: rootMessageSender?.displayname, + rootMessageText: rootMessageText, + lastMessageTime: lastMessageTime, + summaryModel: summaryModel) + } + + private func rootMessageText(forThread thread: MXThread) -> String? { + guard let eventFormatter = eventFormatter else { + return nil + } + guard let message = thread.rootMessage else { + return nil + } + let formatterError = UnsafeMutablePointer.allocate(capacity: 1) + return eventFormatter.string(from: message, + with: roomState, + error: formatterError) + } + + private func lastMessageTextAndTime(forThread thread: MXThread) -> (String?, String?) { + guard let eventFormatter = eventFormatter else { + return (nil, nil) + } + guard let message = thread.lastMessage else { + return (nil, nil) + } + let formatterError = UnsafeMutablePointer.allocate(capacity: 1) + return ( + eventFormatter.string(from: message, + with: roomState, + error: formatterError), + eventFormatter.dateString(from: message, withTime: true) + ) + } + + private func loadData(showLoading: Bool = true) { + + if showLoading { + viewState = .loading + } + + switch selectedFilterType { + case .all: + threads = session.threadingService.threads(inRoom: roomId) + case .myThreads: + threads = session.threadingService.participatedThreads(inRoom: roomId) + } + + if threads.isEmpty { + viewState = .empty(emptyViewModel) + return + } + + threadsLoaded() + } + + private func threadsLoaded() { + guard let eventFormatter = session.roomSummaryUpdateDelegate as? MXKEventFormatter, + let room = session.room(withRoomId: roomId) else { + // go into loaded state + self.viewState = .loaded + + return + } + + room.state { [weak self] roomState in + guard let self = self else { return } + self.eventFormatter = eventFormatter + self.roomState = roomState + + // go into loaded state + self.viewState = .loaded + } + } + + private func selectThread(_ index: Int) { + guard index < threads.count else { + return + } + let thread = threads[index] + coordinatorDelegate?.threadListViewModelDidSelectThread(self, thread: thread) + } + + private func cancelOperations() { + self.currentOperation?.cancel() + } +} + +extension ThreadListViewModel: MXThreadingServiceDelegate { + + func threadingServiceDidUpdateThreads(_ service: MXThreadingService) { + loadData(showLoading: false) + } + +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewModelProtocol.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewModelProtocol.swift new file mode 100644 index 000000000..d375e9f3a --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewModelProtocol.swift @@ -0,0 +1,59 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +protocol ThreadListViewModelViewDelegate: AnyObject { + func threadListViewModel(_ viewModel: ThreadListViewModelProtocol, didUpdateViewState viewSate: ThreadListViewState) +} + +protocol ThreadListViewModelCoordinatorDelegate: AnyObject { + func threadListViewModelDidLoadThreads(_ viewModel: ThreadListViewModelProtocol) + func threadListViewModelDidSelectThread(_ viewModel: ThreadListViewModelProtocol, thread: MXThread) + func threadListViewModelDidCancel(_ viewModel: ThreadListViewModelProtocol) +} + +/// Protocol describing the view model used by `ThreadListViewController` +protocol ThreadListViewModelProtocol { + + var viewDelegate: ThreadListViewModelViewDelegate? { get set } + var coordinatorDelegate: ThreadListViewModelCoordinatorDelegate? { get set } + + func process(viewAction: ThreadListViewAction) + + var viewState: ThreadListViewState { get } + + var titleModel: ThreadRoomTitleModel { get } + var selectedFilterType: ThreadListFilterType { get } + var numberOfThreads: Int { get } + func threadModel(at index: Int) -> ThreadModel? +} + +enum ThreadListFilterType { + case all + case myThreads + + var title: String { + switch self { + case .all: + return VectorL10n.threadsActionAllThreads + case .myThreads: + return VectorL10n.threadsActionMyThreads + } + } +} diff --git a/Riot/Modules/Threads/ThreadList/ThreadListViewState.swift b/Riot/Modules/Threads/ThreadList/ThreadListViewState.swift new file mode 100644 index 000000000..0bd236788 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/ThreadListViewState.swift @@ -0,0 +1,29 @@ +// File created from ScreenTemplate +// $ createScreen.sh Threads/ThreadList ThreadList +/* + Copyright 2021 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 + +/// ThreadListViewController view state +enum ThreadListViewState { + case idle + case loading + case loaded + case empty(_ viewModel: ThreadListEmptyModel) + case showingFilterTypes + case error(Error) +} diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadModel.swift b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadModel.swift new file mode 100644 index 000000000..28b68303d --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadModel.swift @@ -0,0 +1,25 @@ +// +// Copyright 2021 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 + +struct ThreadModel { + let rootMessageSenderAvatar: AvatarViewDataProtocol? + let rootMessageSenderDisplayName: String? + let rootMessageText: String? + let lastMessageTime: String? + let summaryModel: ThreadSummaryModel? +} diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift new file mode 100644 index 000000000..4dfd8d1f6 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.swift @@ -0,0 +1,66 @@ +// +// Copyright 2021 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 +import Reusable + +class ThreadTableViewCell: UITableViewCell { + + private enum Constants { + static let separatorInset: UIEdgeInsets = UIEdgeInsets(top: 0, left: 56, bottom: 0, right: 0) + } + + @IBOutlet private weak var rootMessageAvatarView: UserAvatarView! + @IBOutlet private weak var rootMessageSenderLabel: UILabel! + @IBOutlet private weak var rootMessageContentLabel: UILabel! + @IBOutlet private weak var lastMessageTimeLabel: UILabel! + @IBOutlet private weak var summaryView: ThreadSummaryView! + + override func awakeFromNib() { + super.awakeFromNib() + + separatorInset = Constants.separatorInset + } + + func configure(withModel model: ThreadModel) { + if let rootAvatar = model.rootMessageSenderAvatar { + rootMessageAvatarView.fill(with: rootAvatar) + } else { + rootMessageAvatarView.avatarImageView.image = nil + } + rootMessageSenderLabel.text = model.rootMessageSenderDisplayName + rootMessageContentLabel.text = model.rootMessageText + lastMessageTimeLabel.text = model.lastMessageTime + if let summaryModel = model.summaryModel { + summaryView.configure(withModel: summaryModel) + } + } + +} + +extension ThreadTableViewCell: NibReusable {} + +extension ThreadTableViewCell: Themable { + + func update(theme: Theme) { + rootMessageAvatarView.backgroundColor = .clear + rootMessageContentLabel.textColor = theme.colors.primaryContent + lastMessageTimeLabel.textColor = theme.colors.secondaryContent + summaryView.update(theme: theme) + summaryView.backgroundColor = .clear + } + +} diff --git a/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib new file mode 100644 index 000000000..faea34907 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/Views/Cell/ThreadTableViewCell.xib @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyModel.swift b/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyModel.swift new file mode 100644 index 000000000..74162b2a0 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyModel.swift @@ -0,0 +1,26 @@ +// +// Copyright 2021 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 + +struct ThreadListEmptyModel { + let icon: UIImage? + let title: String? + let info: String? + let tip: String? + let showAllThreadsButtonTitle: String? + let showAllThreadsButtonHidden: Bool +} diff --git a/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyView.swift b/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyView.swift new file mode 100644 index 000000000..523282378 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyView.swift @@ -0,0 +1,74 @@ +// +// Copyright 2021 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 Reusable + +@objc +protocol ThreadListEmptyViewDelegate: AnyObject { + func threadListEmptyViewTappedShowAllThreads(_ emptyView: ThreadListEmptyView) +} + +/// View to be shown on the thread list screen when no thread is available. Use a `ThreadListEmptyModel` instance to configure. +class ThreadListEmptyView: UIView { + + @IBOutlet weak var delegate: ThreadListEmptyViewDelegate? + + @IBOutlet private weak var iconBackgroundView: UIView! + @IBOutlet private weak var iconView: UIImageView! + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var infoLabel: UILabel! + @IBOutlet private weak var tipLabel: UILabel! + @IBOutlet private weak var showAllThreadsButton: UIButton! + + required init?(coder: NSCoder) { + super.init(coder: coder) + loadNibContent() + } + + func configure(withModel model: ThreadListEmptyModel) { + iconView.image = model.icon + titleLabel.text = model.title + infoLabel.text = model.info + tipLabel.text = model.tip + showAllThreadsButton.setTitle(model.showAllThreadsButtonTitle, + for: .normal) + showAllThreadsButton.isHidden = model.showAllThreadsButtonHidden + + titleLabel.isHidden = titleLabel.text?.isEmpty ?? true + infoLabel.isHidden = infoLabel.text?.isEmpty ?? true + tipLabel.isHidden = tipLabel.text?.isEmpty ?? true + } + + @IBAction private func showAllThreadsButtonTapped(_ sender: UIButton) { + delegate?.threadListEmptyViewTappedShowAllThreads(self) + } + +} + +extension ThreadListEmptyView: NibOwnerLoadable {} + +extension ThreadListEmptyView: Themable { + + func update(theme: Theme) { + iconBackgroundView.backgroundColor = theme.colors.system + titleLabel.textColor = theme.colors.primaryContent + infoLabel.textColor = theme.colors.secondaryContent + tipLabel.textColor = theme.colors.secondaryContent + showAllThreadsButton.tintColor = theme.colors.accent + } + +} diff --git a/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyView.xib b/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyView.xib new file mode 100644 index 000000000..50c62af99 --- /dev/null +++ b/Riot/Modules/Threads/ThreadList/Views/Empty/ThreadListEmptyView.xib @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Riot/Modules/Threads/ThreadsCoordinator.swift b/Riot/Modules/Threads/ThreadsCoordinator.swift new file mode 100644 index 000000000..05de0c434 --- /dev/null +++ b/Riot/Modules/Threads/ThreadsCoordinator.swift @@ -0,0 +1,188 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Threads Threads ThreadList +/* + Copyright 2021 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 + +@objcMembers +final class ThreadsCoordinator: NSObject, ThreadsCoordinatorProtocol { + + // MARK: - Properties + + // MARK: Private + + private let parameters: ThreadsCoordinatorParameters + private var selectedThreadCoordinator: RoomCoordinator? + + private var navigationRouter: NavigationRouterType { + return self.parameters.navigationRouter + } + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + weak var delegate: ThreadsCoordinatorDelegate? + + // MARK: - Setup + + init(parameters: ThreadsCoordinatorParameters) { + self.parameters = parameters + super.init() + NotificationCenter.default.addObserver(self, + selector: #selector(didPopModule(_:)), + name: NavigationRouter.didPopModule, + object: nil) + } + + // MARK: - Public + + func start() { + + let rootCoordinator: Coordinator & Presentable + if let threadId = parameters.threadId { + rootCoordinator = createThreadCoordinator(forThreadId: threadId) + } else { + rootCoordinator = createThreadListCoordinator() + } + + rootCoordinator.start() + + self.add(childCoordinator: rootCoordinator) + + // Detect when view controller has been dismissed by gesture when presented modally (not in full screen). + self.navigationRouter.toPresentable().presentationController?.delegate = self + + if self.navigationRouter.modules.isEmpty == false { + self.navigationRouter.push(rootCoordinator, animated: true, popCompletion: { [weak self] in + self?.remove(childCoordinator: rootCoordinator) + }) + } else { + self.navigationRouter.setRootModule(rootCoordinator) { [weak self] in + self?.remove(childCoordinator: rootCoordinator) + } + } + } + + func stop() { + if selectedThreadCoordinator != nil { + let modules = self.navigationRouter.modules + // if a thread is selected from the thread list coordinator, then navigation stack will look like: + // ... -> Screen A -> Thread List Screen -> Thread Screen + // we'll try to pop to Screen A here + // sanity check: navigation stack contains at least 3 items + guard modules.count >= 3 else { + return + } + let moduleToGoBack = modules[modules.count - 3] + self.navigationRouter.popToModule(moduleToGoBack, animated: true) + } else { + self.navigationRouter.popModule(animated: true) + } + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + // MARK: - Private + + @objc + private func didPopModule(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let module = userInfo[NavigationRouter.NotificationUserInfoKey.module] as? Presentable, + let selectedThreadCoordinator = selectedThreadCoordinator else { + return + } + + if module.toPresentable() == selectedThreadCoordinator.toPresentable() { + selectedThreadCoordinator.delegate = nil + remove(childCoordinator: selectedThreadCoordinator) + self.selectedThreadCoordinator = nil + } + } + + private func createThreadListCoordinator() -> ThreadListCoordinator { + let coordinatorParameters = ThreadListCoordinatorParameters(session: self.parameters.session, + roomId: self.parameters.roomId) + let coordinator = ThreadListCoordinator(parameters: coordinatorParameters) + coordinator.delegate = self + return coordinator + } + + private func createThreadCoordinator(forThreadId threadId: String) -> RoomCoordinator { + let parameters = RoomCoordinatorParameters(navigationRouter: navigationRouter, + navigationRouterStore: nil, + session: parameters.session, + roomId: parameters.roomId, + eventId: nil, + threadId: threadId, + displayConfiguration: .forThreads) + let coordinator = RoomCoordinator(parameters: parameters) + coordinator.delegate = self + return coordinator + } +} + +// MARK: - UIAdaptivePresentationControllerDelegate +extension ThreadsCoordinator: UIAdaptivePresentationControllerDelegate { + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + self.delegate?.threadsCoordinatorDidDismissInteractively(self) + } +} + +// MARK: - ThreadListCoordinatorDelegate +extension ThreadsCoordinator: ThreadListCoordinatorDelegate { + func threadListCoordinatorDidLoadThreads(_ coordinator: ThreadListCoordinatorProtocol) { + + } + + func threadListCoordinatorDidSelectThread(_ coordinator: ThreadListCoordinatorProtocol, thread: MXThread) { + let roomCoordinator = createThreadCoordinator(forThreadId: thread.id) + selectedThreadCoordinator = roomCoordinator + roomCoordinator.start() + self.add(childCoordinator: roomCoordinator) + } + + func threadListCoordinatorDidCancel(_ coordinator: ThreadListCoordinatorProtocol) { + self.delegate?.threadsCoordinatorDidComplete(self) + } +} + +// MARK: - RoomCoordinatorDelegate + +extension ThreadsCoordinator: RoomCoordinatorDelegate { + + func roomCoordinatorDidLeaveRoom(_ coordinator: RoomCoordinatorProtocol) { + + } + + func roomCoordinatorDidCancelRoomPreview(_ coordinator: RoomCoordinatorProtocol) { + + } + + func roomCoordinator(_ coordinator: RoomCoordinatorProtocol, didSelectRoomWithId roomId: String, eventId: String?) { + self.delegate?.threadsCoordinatorDidSelect(self, roomId: roomId, eventId: eventId) + } + + func roomCoordinatorDidDismissInteractively(_ coordinator: RoomCoordinatorProtocol) { + + } + +} diff --git a/Riot/Modules/Threads/ThreadsCoordinatorBridgePresenter.swift b/Riot/Modules/Threads/ThreadsCoordinatorBridgePresenter.swift new file mode 100644 index 000000000..43879b292 --- /dev/null +++ b/Riot/Modules/Threads/ThreadsCoordinatorBridgePresenter.swift @@ -0,0 +1,149 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Threads Threads ThreadList +/* + Copyright 2021 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 + +@objc protocol ThreadsCoordinatorBridgePresenterDelegate { + func threadsCoordinatorBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: ThreadsCoordinatorBridgePresenter) + func threadsCoordinatorBridgePresenterDelegateDidSelect(_ coordinatorBridgePresenter: ThreadsCoordinatorBridgePresenter, + roomId: String, + eventId: String?) + func threadsCoordinatorBridgePresenterDidDismissInteractively(_ coordinatorBridgePresenter: ThreadsCoordinatorBridgePresenter) +} + +/// ThreadsCoordinatorBridgePresenter enables to start ThreadsCoordinator from a view controller. +/// This bridge is used while waiting for global usage of coordinator pattern. +/// **WARNING**: This class breaks the Coordinator abstraction and it has been introduced for **Objective-C compatibility only** (mainly for integration in legacy view controllers). Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator. +@objcMembers +final class ThreadsCoordinatorBridgePresenter: NSObject { + + // MARK: - Constants + + private enum NavigationType { + case present + case push + } + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let roomId: String + private let threadId: String? + private var navigationType: NavigationType = .present + private var coordinator: ThreadsCoordinator? + + // MARK: Public + + weak var delegate: ThreadsCoordinatorBridgePresenterDelegate? + + // MARK: - Setup + + /// Initializer + /// - Parameters: + /// - session: Session instance + /// - roomId: Room identifier + /// - threadId: Thread identifier. Specified thread will be opened if provided, the thread list otherwise + init(session: MXSession, + roomId: String, + threadId: String?) { + self.session = session + self.roomId = roomId + self.threadId = threadId + super.init() + } + + // MARK: - Public + + // NOTE: Default value feature is not compatible with Objective-C. + // func present(from viewController: UIViewController, animated: Bool) { + // self.present(from: viewController, animated: animated) + // } + + func present(from viewController: UIViewController, animated: Bool) { + + let threadsCoordinatorParameters = ThreadsCoordinatorParameters(session: self.session, + roomId: self.roomId, + threadId: self.threadId) + + let threadsCoordinator = ThreadsCoordinator(parameters: threadsCoordinatorParameters) + threadsCoordinator.delegate = self + let presentable = threadsCoordinator.toPresentable() + viewController.present(presentable, animated: animated, completion: nil) + threadsCoordinator.start() + + self.coordinator = threadsCoordinator + self.navigationType = .present + } + + func push(from navigationController: UINavigationController, animated: Bool) { + + let navigationRouter = NavigationRouterStore.shared.navigationRouter(for: navigationController) + + let threadsCoordinatorParameters = ThreadsCoordinatorParameters(session: self.session, + roomId: self.roomId, + threadId: self.threadId, + navigationRouter: navigationRouter) + + let threadsCoordinator = ThreadsCoordinator(parameters: threadsCoordinatorParameters) + threadsCoordinator.delegate = self + threadsCoordinator.start() // Will trigger the view controller push + + self.coordinator = threadsCoordinator + self.navigationType = .push + } + + func dismiss(animated: Bool, completion: (() -> Void)?) { + guard let coordinator = self.coordinator else { + return + } + + switch navigationType { + case .present: + // Dismiss modal + coordinator.toPresentable().dismiss(animated: animated) { + self.coordinator = nil + + completion?() + } + case .push: + // stop coordinator to pop modules as needed + coordinator.stop() + self.coordinator = nil + + completion?() + } + } +} + +// MARK: - ThreadsCoordinatorDelegate +extension ThreadsCoordinatorBridgePresenter: ThreadsCoordinatorDelegate { + + func threadsCoordinatorDidComplete(_ coordinator: ThreadsCoordinatorProtocol) { + self.delegate?.threadsCoordinatorBridgePresenterDelegateDidComplete(self) + } + + func threadsCoordinatorDidSelect(_ coordinator: ThreadsCoordinatorProtocol, roomId: String, eventId: String?) { + self.delegate?.threadsCoordinatorBridgePresenterDelegateDidSelect(self, roomId: roomId, eventId: eventId) + } + + func threadsCoordinatorDidDismissInteractively(_ coordinator: ThreadsCoordinatorProtocol) { + self.delegate?.threadsCoordinatorBridgePresenterDidDismissInteractively(self) + } +} diff --git a/Riot/Modules/Threads/ThreadsCoordinatorParameters.swift b/Riot/Modules/Threads/ThreadsCoordinatorParameters.swift new file mode 100644 index 000000000..83a2d47be --- /dev/null +++ b/Riot/Modules/Threads/ThreadsCoordinatorParameters.swift @@ -0,0 +1,45 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Threads Threads ThreadList +/* + Copyright 2021 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 + +/// ThreadsCoordinator input parameters +struct ThreadsCoordinatorParameters { + + /// The Matrix session + let session: MXSession + + /// Room identifier + let roomId: String + + /// Thread identifier. Specified thread will be opened if provided, the thread list otherwise + let threadId: String? + + /// The navigation router that manage physical navigation + let navigationRouter: NavigationRouterType + + init(session: MXSession, + roomId: String, + threadId: String?, + navigationRouter: NavigationRouterType? = nil) { + self.session = session + self.roomId = roomId + self.threadId = threadId + self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController()) + } +} diff --git a/Riot/Modules/Threads/ThreadsCoordinatorProtocol.swift b/Riot/Modules/Threads/ThreadsCoordinatorProtocol.swift new file mode 100644 index 000000000..69d6a90f2 --- /dev/null +++ b/Riot/Modules/Threads/ThreadsCoordinatorProtocol.swift @@ -0,0 +1,33 @@ +// File created from FlowTemplate +// $ createRootCoordinator.sh Threads Threads ThreadList +/* + Copyright 2021 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 + +protocol ThreadsCoordinatorDelegate: AnyObject { + func threadsCoordinatorDidComplete(_ coordinator: ThreadsCoordinatorProtocol) + + func threadsCoordinatorDidSelect(_ coordinator: ThreadsCoordinatorProtocol, roomId: String, eventId: String?) + + /// Called when the view has been dismissed by gesture when presented modally (not in full screen). + func threadsCoordinatorDidDismissInteractively(_ coordinator: ThreadsCoordinatorProtocol) +} + +/// `ThreadsCoordinatorProtocol` is a protocol describing a Coordinator that handle xxxxxxx navigation flow. +protocol ThreadsCoordinatorProtocol: Coordinator, Presentable { + var delegate: ThreadsCoordinatorDelegate? { get } +} diff --git a/Riot/Routers/NavigationModule.swift b/Riot/Routers/NavigationModule.swift new file mode 100644 index 000000000..01c54bb98 --- /dev/null +++ b/Riot/Routers/NavigationModule.swift @@ -0,0 +1,36 @@ +// +// Copyright 2021 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 + +/// Structure used to pass modules to routers with pop completion blocks. +struct NavigationModule { + /// Actual presentable of the module + let presentable: Presentable + + /// Block to be called when the module is popped + let popCompletion: (() -> Void)? +} + +// MARK: - CustomStringConvertible + +extension NavigationModule: CustomStringConvertible { + + var description: String { + return "NavigationModule: \(presentable), pop completion: \(String(describing: popCompletion))" + } + +} diff --git a/Riot/Routers/NavigationRouter.swift b/Riot/Routers/NavigationRouter.swift index 25a0aa333..23c05aff1 100755 --- a/Riot/Routers/NavigationRouter.swift +++ b/Riot/Routers/NavigationRouter.swift @@ -117,13 +117,13 @@ final class NavigationRouter: NSObject, NavigationRouterType { self.didPushViewController(controller) } - func setModules(_ modules: [Presentable], hideNavigationBar: Bool, animated: Bool) { + func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool) { MXLog.debug("[NavigationRouter] Set modules \(modules)") - let controllers = modules.map { (presentable) -> UIViewController in - let controller = presentable.toPresentable() - self.addModule(presentable, for: controller) + let controllers = modules.map { (module) -> UIViewController in + let controller = module.presentable.toPresentable() + self.addModule(module.presentable, for: controller) return controller } @@ -147,8 +147,8 @@ final class NavigationRouter: NSObject, NavigationRouterType { } // Add again controller to module association, in case same modules instance are added back - modules.forEach { (presentable) in - self.addModule(presentable, for: presentable.toPresentable()) + modules.forEach { (module) in + self.addModule(module.presentable, for: module.presentable.toPresentable()) } controllers.forEach { @@ -221,6 +221,36 @@ final class NavigationRouter: NSObject, NavigationRouterType { self.didPushViewController(controller) } + func push(_ modules: [NavigationModule], animated: Bool) { + MXLog.debug("[NavigationRouter] Push modules \(modules)") + + // Avoid pushing any UINavigationController onto stack + guard modules.first(where: { $0.presentable.toPresentable() is UINavigationController }) == nil else { + MXLog.error("Cannot push a UINavigationController to NavigationRouter") + return + } + + for module in modules { + let controller = module.presentable.toPresentable() + self.addModule(module.presentable, for: controller) + + if let completion = module.popCompletion { + completions[controller] = completion + } + + self.willPushViewController(controller) + } + + var viewControllers = navigationController.viewControllers + viewControllers.append(contentsOf: modules.map({ $0.presentable.toPresentable() })) + navigationController.setViewControllers(viewControllers, animated: animated) + + for module in modules { + let controller = module.presentable.toPresentable() + self.didPushViewController(controller) + } + } + func popModule(animated: Bool = true) { MXLog.debug("[NavigationRouter] Pop module") diff --git a/Riot/Routers/NavigationRouterType.swift b/Riot/Routers/NavigationRouterType.swift index c7c9aa2ec..f1275efa0 100755 --- a/Riot/Routers/NavigationRouterType.swift +++ b/Riot/Routers/NavigationRouterType.swift @@ -42,10 +42,10 @@ protocol NavigationRouterType: AnyObject, Presentable { /// Set view controllers stack of navigation controller /// - Parameters: - /// - modules: The presentables stack to set. + /// - modules: The modules stack to set. /// - hideNavigationBar: Specify true to hide the UINavigationBar. /// - animated: Specify true to animate the transition. - func setModules(_ modules: [Presentable], hideNavigationBar: Bool, animated: Bool) + func setModules(_ modules: [NavigationModule], hideNavigationBar: Bool, animated: Bool) /// Pop to root view controller of navigation controller and remove all others /// @@ -64,6 +64,12 @@ protocol NavigationRouterType: AnyObject, Presentable { /// - Parameter popCompletion: Completion called when `module` is removed from the navigation stack. func push(_ module: Presentable, animated: Bool, popCompletion: (() -> Void)?) + /// Push some view controllers on navigation controller stack + /// + /// - Parameter modules: Modules to push + /// - Parameter animated: Specify true to animate the transition. + func push(_ modules: [NavigationModule], animated: Bool) + /// Pop last view controller from navigation controller stack /// /// - Parameter animated: Specify true to animate the transition. @@ -93,7 +99,37 @@ extension NavigationRouterType { setRootModule(module, hideNavigationBar: false, animated: false, popCompletion: popCompletion) } + func setModules(_ modules: [NavigationModule], animated: Bool) { + setModules(modules, hideNavigationBar: false, animated: animated) + } + func setModules(_ modules: [Presentable], animated: Bool) { setModules(modules, hideNavigationBar: false, animated: animated) } + +} + +// MARK: - Presentable <--> NavigationModule Transitive Methods + +extension NavigationRouterType { + + func setRootModule(_ module: NavigationModule) { + setRootModule(module.presentable, popCompletion: module.popCompletion) + } + + func push(_ module: NavigationModule, animated: Bool) { + push(module.presentable, animated: animated, popCompletion: module.popCompletion) + } + + func setModules(_ modules: [Presentable], hideNavigationBar: Bool, animated: Bool) { + setModules(modules.map { $0.toModule() }, + hideNavigationBar: hideNavigationBar, + animated: animated) + } + + func push(_ modules: [Presentable], animated: Bool) { + push(modules.map { $0.toModule() }, + animated: animated) + } + } diff --git a/Riot/Routers/Presentable.swift b/Riot/Routers/Presentable.swift index fd42d3059..e9f760393 100755 --- a/Riot/Routers/Presentable.swift +++ b/Riot/Routers/Presentable.swift @@ -26,3 +26,13 @@ extension UIViewController: Presentable { return self } } + +extension Presentable { + + /// Returns a new module from the presentable without a pop completion block + /// - Returns: Module + func toModule() -> NavigationModule { + return NavigationModule(presentable: self, popCompletion: nil) + } + +} diff --git a/Riot/Utils/EventFormatter.m b/Riot/Utils/EventFormatter.m index d97386aaa..a804f1173 100644 --- a/Riot/Utils/EventFormatter.m +++ b/Riot/Utils/EventFormatter.m @@ -65,6 +65,32 @@ static NSString *const kEventFormatterTimeFormat = @"HH:mm"; - (NSAttributedString *)attributedStringFromEvent:(MXEvent *)event withRoomState:(MXRoomState *)roomState error:(MXKEventFormatterError *)error { + if (event.redactedBecause) + { + // Check whether the event is a thread root or redacted information is required + if ([mxSession.threadingService isEventThreadRoot:event] + || self.settings.showRedactionsInRoomHistory) + { + UIFont *font = self.defaultTextFont; + UIColor *color = ThemeService.shared.theme.colors.secondaryContent; + NSString *string = [NSString stringWithFormat:@" %@", VectorL10n.eventFormatterMessageDeleted]; + NSAttributedString *attrString = [[NSAttributedString alloc] initWithString:string + attributes:@{ + NSFontAttributeName: font, + NSForegroundColorAttributeName: color + }]; + + CGSize imageSize = CGSizeMake(20, 20); + NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; + attachment.image = [[[UIImage imageNamed:@"room_context_menu_delete"] vc_resizedWith:imageSize] vc_tintedImageUsingColor:color]; + attachment.bounds = CGRectMake(0, font.descender, imageSize.width, imageSize.height); + NSAttributedString *imageString = [NSAttributedString attributedStringWithAttachment:attachment]; + + NSMutableAttributedString *result = [[NSMutableAttributedString alloc] initWithAttributedString:imageString]; + [result appendAttributedString:attrString]; + return result; + } + } BOOL isEventSenderMyUser = [event.sender isEqualToString:mxSession.myUserId]; // Build strings for widget events diff --git a/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift index c77a9dd86..b43eb8d7b 100644 --- a/RiotNSE/NotificationService.swift +++ b/RiotNSE/NotificationService.swift @@ -427,7 +427,7 @@ class NotificationService: UNNotificationServiceExtension { if event.isReply() { let parser = MXReplyEventParser() let replyParts = parser.parse(event) - notificationBody = replyParts.bodyParts.replyText + notificationBody = replyParts?.bodyParts.replyText } else { notificationBody = messageContent } diff --git a/RiotShareExtension/Shared/ForwardingShareItemSender.swift b/RiotShareExtension/Shared/ForwardingShareItemSender.swift index 84b866b27..3909812db 100644 --- a/RiotShareExtension/Shared/ForwardingShareItemSender.swift +++ b/RiotShareExtension/Shared/ForwardingShareItemSender.swift @@ -51,7 +51,7 @@ class ForwardingShareItemSender: NSObject, ShareItemSenderProtocol { dispatchGroup.enter() var localEcho: MXEvent? - room.sendMessage(withContent: event.content, localEcho: &localEcho) { result in + room.sendMessage(withContent: event.content, threadId: nil, localEcho: &localEcho) { result in switch result { case .failure(let innerError): errors.append(innerError) diff --git a/RiotShareExtension/Sources/ShareItemSender.m b/RiotShareExtension/Sources/ShareItemSender.m index e1cd1add6..fdc213033 100644 --- a/RiotShareExtension/Sources/ShareItemSender.m +++ b/RiotShareExtension/Sources/ShareItemSender.m @@ -525,7 +525,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) dispatch_group_t dispatchGroup = dispatch_group_create(); for (MXRoom *room in rooms) { dispatch_group_enter(dispatchGroup); - [room sendTextMessage:text success:^(NSString *eventId) { + [room sendTextMessage:text threadId:nil success:^(NSString *eventId) { dispatch_group_leave(dispatchGroup); } failure:^(NSError *innerError) { MXLogError(@"[ShareItemSender] sendTextMessage failed with error %@", error); @@ -565,7 +565,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) dispatch_group_t dispatchGroup = dispatch_group_create(); for (MXRoom *room in rooms) { dispatch_group_enter(dispatchGroup); - [room sendFile:fileUrl mimeType:mimeType localEcho:nil success:^(NSString *eventId) { + [room sendFile:fileUrl mimeType:mimeType threadId:nil localEcho:nil success:^(NSString *eventId) { dispatch_group_leave(dispatchGroup); } failure:^(NSError *innerError) { MXLogError(@"[ShareItemSender] sendFile failed with error %@", innerError); @@ -616,7 +616,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) dispatch_group_t dispatchGroup = dispatch_group_create(); for (MXRoom *room in rooms) { dispatch_group_enter(dispatchGroup); - [room sendVideoAsset:videoAsset withThumbnail:videoThumbnail localEcho:nil success:^(NSString *eventId) { + [room sendVideoAsset:videoAsset withThumbnail:videoThumbnail threadId:nil localEcho:nil success:^(NSString *eventId) { dispatch_group_leave(dispatchGroup); } failure:^(NSError *innerError) { MXLogError(@"[ShareManager] Failed sending video with error %@", innerError); @@ -702,7 +702,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) dispatch_group_t dispatchGroup = dispatch_group_create(); for (MXRoom *room in rooms) { dispatch_group_enter(dispatchGroup); - [room sendVoiceMessage:fileUrl mimeType:nil duration:0.0 samples:nil localEcho:nil success:^(NSString *eventId) { + [room sendVoiceMessage:fileUrl mimeType:nil duration:0.0 samples:nil threadId:nil localEcho:nil success:^(NSString *eventId) { dispatch_group_leave(dispatchGroup); } failure:^(NSError *innerError) { MXLogError(@"[ShareItemSender] sendVoiceMessage failed with error %@", error); @@ -867,7 +867,7 @@ typedef NS_ENUM(NSInteger, ImageCompressionMode) } dispatch_group_enter(dispatchGroup); - [room sendImage:finalImageData withImageSize:imageSize mimeType:mimeType andThumbnail:thumbnail localEcho:nil success:^(NSString *eventId) { + [room sendImage:finalImageData withImageSize:imageSize mimeType:mimeType andThumbnail:thumbnail threadId:nil localEcho:nil success:^(NSString *eventId) { dispatch_group_leave(dispatchGroup); } failure:^(NSError *innerError) { MXLogError(@"[ShareManager] sendImage failed with error %@", error); diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift index 17bcf2548..7476ac01a 100644 --- a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift @@ -86,7 +86,7 @@ final class PollEditFormCoordinator: Coordinator, Presentable { self.pollEditFormViewModel.dispatch(action: .startLoading) - self.parameters.room.sendPollStart(withContent: pollStartContent, localEcho: nil) { [weak self] result in + self.parameters.room.sendPollStart(withContent: pollStartContent, threadId: nil, localEcho: nil) { [weak self] result in guard let self = self else { return } self.pollEditFormViewModel.dispatch(action: .stopLoading(nil)) diff --git a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift index e54fd8013..13d1f4233 100644 --- a/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift +++ b/RiotSwiftUI/Modules/Room/TimelinePoll/Coordinator/TimelinePollCoordinator.swift @@ -70,6 +70,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel self.parameters.room.sendPollResponse(for: parameters.pollStartEvent, withAnswerIdentifiers: identifiers, + threadId: nil, localEcho: nil, success: nil) { [weak self] error in guard let self = self else { return } @@ -100,7 +101,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel } func endPoll() { - parameters.room.sendPollEnd(for: parameters.pollStartEvent, localEcho: nil, success: nil) { [weak self] error in + parameters.room.sendPollEnd(for: parameters.pollStartEvent, threadId: nil, localEcho: nil, success: nil) { [weak self] error in self?.viewModel.dispatch(action: .showClosingFailure) } } diff --git a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/Service/MatrixSDK/TemplateRoomChatService.swift b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/Service/MatrixSDK/TemplateRoomChatService.swift index 821f4ccac..6d231f744 100644 --- a/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/Service/MatrixSDK/TemplateRoomChatService.swift +++ b/RiotSwiftUI/Modules/Template/TemplateAdvancedRoomsExample/TemplateRoomChat/Service/MatrixSDK/TemplateRoomChatService.swift @@ -57,7 +57,7 @@ class TemplateRoomChatService: TemplateRoomChatServiceProtocol { // MARK: Public func send(textMessage: String) { var localEcho: MXEvent? = nil - room.sendTextMessage(textMessage, localEcho: &localEcho, completion: { _ in }) + room.sendTextMessage(textMessage, threadId: nil, localEcho: &localEcho, completion: { _ in }) } // MARK: Private diff --git a/SiriIntents/IntentHandler.m b/SiriIntents/IntentHandler.m index 9c503501d..d986524fa 100644 --- a/SiriIntents/IntentHandler.m +++ b/SiriIntents/IntentHandler.m @@ -227,36 +227,38 @@ break; } } - + if (isEncrypted) { [MXFileStore setPreloadOptions:0]; - + MXSession *session = [[MXSession alloc] initWithMatrixRestClient:account.mxRestClient]; MXWeakify(session); [session setStore:fileStore success:^{ MXStrongifyAndReturnIfNil(session); - + MXRoom *room = [MXRoom loadRoomFromStore:fileStore withRoomId:roomID matrixSession:session]; - + // Do not warn for unknown devices. We have cross-signing now session.crypto.warnOnUnknowDevices = NO; - + [room sendTextMessage:intent.content + threadId:nil success:^(NSString *eventId) { completeWithCode(INSendMessageIntentResponseCodeSuccess); } failure:^(NSError *error) { completeWithCode(INSendMessageIntentResponseCodeFailure); }]; - + } failure:^(NSError *error) { completeWithCode(INSendMessageIntentResponseCodeFailure); }]; - + return; } [account.mxRestClient sendTextMessageToRoom:roomID + threadId:nil text:intent.content success:^(NSString *eventId) { completeWithCode(INSendMessageIntentResponseCodeSuccess); diff --git a/changelog.d/5094.change b/changelog.d/5094.change new file mode 100644 index 000000000..4995110eb --- /dev/null +++ b/changelog.d/5094.change @@ -0,0 +1 @@ +Permalinks: Create for thread events & handle navigations. diff --git a/changelog.d/5095.change b/changelog.d/5095.change new file mode 100644 index 000000000..4a218fbeb --- /dev/null +++ b/changelog.d/5095.change @@ -0,0 +1 @@ +Search: Navigate to thread view for search results in threads. diff --git a/changelog.d/5117.feature b/changelog.d/5117.feature new file mode 100644 index 000000000..36eff840f --- /dev/null +++ b/changelog.d/5117.feature @@ -0,0 +1 @@ +Threads: Add `View in room` action to the thread root event.