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.