Merge pull request #5089 from vector-im/ismail/5068_start_thread

Start a new Thread by replying to a message
This commit is contained in:
ismailgulek
2022-01-27 02:21:47 +03:00
committed by GitHub
101 changed files with 3868 additions and 350 deletions
+20 -25
View File
@@ -64,27 +64,6 @@
</objects>
<point key="canvasLocation" x="5047" y="-1437"/>
</scene>
<!--Room Context Timeline-->
<scene sceneID="Htr-h8-baq">
<objects>
<viewController title="Room Context Timeline" hidesBottomBarWhenPushed="YES" id="Too-LV-OLW" customClass="RoomViewController" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="yLe-Hk-Sol">
<nil key="title"/>
<view key="titleView" contentMode="scaleToFill" id="djN-zB-Vni" userLabel="Room title view container">
<rect key="frame" x="8" y="2" width="312" height="40"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</navigationItem>
<connections>
<outlet property="roomTitleViewContainer" destination="djN-zB-Vni" id="VQG-Mp-hSa"/>
<segue destination="gkO-rP-nGK" kind="show" identifier="showContactDetails" id="ziz-Xl-QVg"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Yjg-uP-Hcy" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3326" y="-1299"/>
</scene>
<!--Room Search View Controller-->
<scene sceneID="rUg-1s-vHX">
<objects>
@@ -98,9 +77,6 @@
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
<connections>
<segue destination="Too-LV-OLW" kind="show" identifier="showTimeline" id="P1V-0d-mYL"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="bK5-DX-KSF" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
@@ -543,10 +519,29 @@
</objects>
<point key="canvasLocation" x="374" y="449"/>
</scene>
<!--Thread-->
<scene sceneID="Opl-gU-pwm">
<objects>
<viewController storyboardIdentifier="ThreadViewControllerStoryboardId" title="Room" hidesBottomBarWhenPushed="YES" id="R2h-H9-hdJ" userLabel="Thread" customClass="ThreadViewController" customModule="Riot" customModuleProvider="target" sceneMemberID="viewController">
<navigationItem key="navigationItem" id="TFF-nx-BSb">
<nil key="title"/>
<view key="titleView" contentMode="scaleToFill" id="e4J-vI-jzo" userLabel="Room title view container">
<rect key="frame" x="8" y="2" width="312" height="40"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</navigationItem>
<connections>
<outlet property="roomTitleViewContainer" destination="e4J-vI-jzo" id="b1C-TY-2R6"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="kHX-sJ-uYE" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-153" y="-419"/>
</scene>
</scenes>
<inferredMetricsTieBreakers>
<segue reference="Tfl-tq-LQp"/>
<segue reference="f5u-Y1-7nt"/>
</inferredMetricsTieBreakers>
<resources>
<image name="launch_screen_logo" width="240" height="240"/>
@@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 B

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

+21 -2
View File
@@ -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: %@";
+3
View File
@@ -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")
+5
View File
@@ -279,6 +279,11 @@ internal enum StoryboardScene {
internal static let initialScene = InitialSceneType<Riot.TemplateScreenViewController>(storyboard: TemplateScreenViewController.self)
}
internal enum ThreadListViewController: StoryboardType {
internal static let storyboardName = "ThreadListViewController"
internal static let initialScene = InitialSceneType<Riot.ThreadListViewController>(storyboard: ThreadListViewController.self)
}
internal enum UserVerificationSessionStatusViewController: StoryboardType {
internal static let storyboardName = "UserVerificationSessionStatusViewController"
+65 -1
View File
@@ -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")
@@ -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.
+25 -3
View File
@@ -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.");
}];
@@ -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()
@@ -34,6 +34,7 @@ class RoomPreviewNavigationParameters: RoomNavigationParameters {
super.init(roomId: previewData.roomId,
eventId: previewData.eventId,
mxSession: previewData.mxSession,
threadParameters: nil,
presentationParameters: presentationParameters)
}
}
@@ -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)
}
@@ -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:^{
@@ -157,6 +157,7 @@
RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId
eventId:eventId
mxSession:session
threadParameters:nil
presentationParameters:presentationParameters];
[[AppDelegate theDelegate] showRoomWithParameters:parameters];
@@ -164,6 +164,7 @@
RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId
eventId:eventId
mxSession:session
threadParameters:nil
presentationParameters:presentationParameters];
[[AppDelegate theDelegate] showRoomWithParameters:parameters];
@@ -248,6 +248,7 @@
RoomNavigationParameters *parameters = [[RoomNavigationParameters alloc] initWithRoomId:roomId
eventId:nil
mxSession:mxSession
threadParameters:nil
presentationParameters:presentationParameters];
[[AppDelegate theDelegate] showRoomWithParameters:parameters];
}
@@ -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
*/
@@ -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;
@@ -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.
@@ -18,6 +18,13 @@
#import "MXEvent+MatrixKit.h"
#import "MXKSwiftHeader.h"
#import <MatrixSDK/MatrixSDK.h>
@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;
}
@@ -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
/**
@@ -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];
@@ -325,11 +325,11 @@ static NSString *const kHTMLATagRegexPattern = @"<a href=\"(.*?)\">([^<]*)</a>";
// 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 = @"<a href=\"(.*?)\">([^<]*)</a>";
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 = @"<a href=\"(.*?)\">([^<]*)</a>";
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:@"<mx-reply><blockquote><a href=\"%@\">In reply to</a> <a href=\"%@\">%@</a><br>%@</blockquote></mx-reply>%@",
[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 = @"<a href=\"(.*?)\">([^<]*)</a>";
// 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 = @"<a href=\"(.*?)\">([^<]*)</a>";
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 = @"<a href=\"(.*?)\">([^<]*)</a>";
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 = @"<a href=\"(.*?)\">([^<]*)</a>";
{
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;
}
@@ -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<MXKRoomBubbleCellDataStoring>)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];
@@ -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:
+30 -14
View File
@@ -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<TypingUserInfo *> *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 <MXKDataSourceDelegate>
- (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
+103 -12
View File
@@ -29,7 +29,7 @@
const CGFloat kTypingCellHeight = 24;
@interface RoomDataSource() <BubbleReactionsViewModelDelegate, URLPreviewViewDelegate>
@interface RoomDataSource() <BubbleReactionsViewModelDelegate, URLPreviewViewDelegate, ThreadSummaryViewDelegate>
{
// 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
@@ -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 {
}
@@ -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
+54 -7
View File
@@ -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) {
@@ -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) {
@@ -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)
}
}
@@ -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)
}
@@ -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)
}
+1
View File
@@ -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 }
}
+20 -2
View File
@@ -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.
+366 -93
View File
@@ -93,7 +93,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
@interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, RoomParticipantsViewControllerDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
ReactionHistoryCoordinatorBridgePresenterDelegate, CameraPresenterDelegate, MediaPickerCoordinatorBridgePresenterDelegate,
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, UserSuggestionCoordinatorBridgeDelegate>
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<RoomContextualMenuItem*> *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<MXKCellRendering>)cell animated:(BOOL)animated
@@ -6025,20 +6179,33 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}
- (RoomContextualMenuItem *)copyMenuItemWithEvent:(MXEvent*)event andCell:(id<MXKCellRendering>)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<MXKCellRendering>)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<MXKCellRendering>)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<MXKCellRendering>)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
@@ -18,9 +18,4 @@
@interface RoomFilesSearchViewController : MXKSearchViewController
/**
The event selected in the search results
*/
@property (nonatomic, readonly) MXEvent *selectedEvent;
@end
@@ -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
@@ -20,9 +20,4 @@
@interface RoomMessagesSearchViewController : MXKSearchViewController
/**
The event selected in the search results
*/
@property (nonatomic, readonly) MXEvent *selectedEvent;
@end
@@ -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
@@ -27,4 +27,6 @@
+ (instancetype)instantiate;
- (void)selectEvent:(MXEvent *)event;
@end
@@ -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
@@ -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<UIView*> *tmpSubviews;
@@ -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)
@@ -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
}
@@ -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<AnyHashable>) {
cell.updateTickView(withFailedEventIds: failedEventIds)
}
@@ -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<AnyHashable>)
@@ -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?
}
@@ -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<MXKEventFormatterError>.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
}
}
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ThreadSummaryView" customModule="Riot" customModuleProvider="target">
<connections>
<outlet property="iconView" destination="vva-PV-3Ya" id="e1B-Zp-pni"/>
<outlet property="lastMessageAvatarView" destination="9wW-1f-f69" id="xiI-t8-Q56"/>
<outlet property="lastMessageContentLabel" destination="DVT-JI-3kw" id="O6N-ev-FRz"/>
<outlet property="numberOfRepliesLabel" destination="GcG-W8-9LR" id="hzP-Ea-C6l"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB">
<rect key="frame" x="0.0" y="0.0" width="414" height="32"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TFL-sS-eJc">
<rect key="frame" x="8" y="4" width="398" height="24"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="room_context_menu_reply_in_thread" translatesAutoresizingMaskIntoConstraints="NO" id="vva-PV-3Ya">
<rect key="frame" x="1" y="3" width="18" height="18"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" secondItem="vva-PV-3Ya" secondAttribute="height" multiplier="1:1" id="972-WJ-2Zq"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="1" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="GcG-W8-9LR">
<rect key="frame" x="25" y="0.0" width="6" height="24"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="9wW-1f-f69" customClass="UserAvatarView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="39" y="0.0" width="24" height="24"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" secondItem="9wW-1f-f69" secondAttribute="height" multiplier="1:1" id="V4H-JA-w4O"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="DVT-JI-3kw">
<rect key="frame" x="71" y="0.0" width="319" height="24"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="vva-PV-3Ya" firstAttribute="top" secondItem="TFL-sS-eJc" secondAttribute="top" constant="3" id="3JV-P7-s7n"/>
<constraint firstAttribute="bottom" secondItem="DVT-JI-3kw" secondAttribute="bottom" id="ArM-9P-35J"/>
<constraint firstAttribute="bottom" secondItem="GcG-W8-9LR" secondAttribute="bottom" id="FI4-bk-goz"/>
<constraint firstItem="9wW-1f-f69" firstAttribute="top" secondItem="TFL-sS-eJc" secondAttribute="top" id="GBe-gi-Iwc"/>
<constraint firstItem="DVT-JI-3kw" firstAttribute="top" secondItem="TFL-sS-eJc" secondAttribute="top" id="MSs-PD-tov"/>
<constraint firstItem="GcG-W8-9LR" firstAttribute="leading" secondItem="vva-PV-3Ya" secondAttribute="trailing" constant="6" id="PhI-J3-Ycb"/>
<constraint firstItem="GcG-W8-9LR" firstAttribute="top" secondItem="TFL-sS-eJc" secondAttribute="top" id="Twp-gS-w3u"/>
<constraint firstAttribute="bottom" secondItem="9wW-1f-f69" secondAttribute="bottom" id="VG5-XU-DAK"/>
<constraint firstAttribute="trailing" secondItem="DVT-JI-3kw" secondAttribute="trailing" constant="8" id="bX2-Ha-8bf"/>
<constraint firstItem="DVT-JI-3kw" firstAttribute="leading" secondItem="9wW-1f-f69" secondAttribute="trailing" constant="8" id="qGg-0A-C6M"/>
<constraint firstItem="9wW-1f-f69" firstAttribute="leading" secondItem="GcG-W8-9LR" secondAttribute="trailing" constant="8" id="s2V-X9-cyI"/>
<constraint firstAttribute="bottom" secondItem="vva-PV-3Ya" secondAttribute="bottom" constant="3" id="smY-cv-CoE"/>
<constraint firstItem="vva-PV-3Ya" firstAttribute="leading" secondItem="TFL-sS-eJc" secondAttribute="leading" constant="1" id="vyh-e4-Vy3"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="bottom" secondItem="TFL-sS-eJc" secondAttribute="bottom" constant="4" id="GJq-Pw-T0A"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="TFL-sS-eJc" secondAttribute="trailing" constant="8" id="RR6-Uu-dD9"/>
<constraint firstItem="TFL-sS-eJc" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="8" id="lbH-ay-p0c"/>
<constraint firstItem="TFL-sS-eJc" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" constant="4" id="yqe-iO-Cz5"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<point key="canvasLocation" x="-23.188405797101453" y="-179.46428571428569"/>
</view>
</objects>
<resources>
<image name="room_context_menu_reply_in_thread" width="18" height="18"/>
</resources>
</document>
@@ -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)
}
@@ -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
}
}
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="ThreadRoomTitleView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="243" height="64"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Ami-Cg-fcA">
<rect key="frame" x="0.0" y="0.0" width="243" height="64"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Thread" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="BnG-NU-7Mg">
<rect key="frame" x="18" y="22" width="56.5" height="20.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="FJB-2F-rrQ" customClass="RoomAvatarView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="82.5" y="24" width="16" height="16"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="16" id="Fg7-y5-fEC"/>
<constraint firstAttribute="height" constant="16" id="Qxm-RC-uC5"/>
</constraints>
</view>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Mli-PC-WUh">
<rect key="frame" x="92.5" y="28" width="12" height="12"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" secondItem="Mli-PC-WUh" secondAttribute="height" multiplier="1:1" id="Ohw-dy-qg0"/>
<constraint firstAttribute="width" constant="12" id="nKB-SN-cO0"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Room name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="8lk-sN-3IP">
<rect key="frame" x="109.5" y="24.5" width="67" height="15"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="Mli-PC-WUh" firstAttribute="centerX" secondItem="FJB-2F-rrQ" secondAttribute="trailing" id="BU4-yl-DrP"/>
<constraint firstItem="BnG-NU-7Mg" firstAttribute="leading" secondItem="Ami-Cg-fcA" secondAttribute="leading" constant="18" id="ES6-mL-Y9F"/>
<constraint firstItem="8lk-sN-3IP" firstAttribute="centerY" secondItem="Ami-Cg-fcA" secondAttribute="centerY" id="S0S-6y-Vkn"/>
<constraint firstItem="FJB-2F-rrQ" firstAttribute="leading" secondItem="BnG-NU-7Mg" secondAttribute="trailing" constant="8" id="SQk-zN-CO6"/>
<constraint firstItem="FJB-2F-rrQ" firstAttribute="centerY" secondItem="Ami-Cg-fcA" secondAttribute="centerY" id="nY0-2s-Wgo"/>
<constraint firstItem="8lk-sN-3IP" firstAttribute="leading" secondItem="FJB-2F-rrQ" secondAttribute="trailing" constant="11" id="ql2-B3-82Y"/>
<constraint firstItem="BnG-NU-7Mg" firstAttribute="centerY" secondItem="Ami-Cg-fcA" secondAttribute="centerY" id="rwC-ak-Ydb"/>
<constraint firstItem="Mli-PC-WUh" firstAttribute="bottom" secondItem="FJB-2F-rrQ" secondAttribute="bottom" id="ulL-Xh-oCC"/>
</constraints>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="Ami-Cg-fcA" secondAttribute="trailing" id="a9m-d6-0go"/>
<constraint firstAttribute="bottom" secondItem="Ami-Cg-fcA" secondAttribute="bottom" id="fEm-nj-yVF"/>
<constraint firstItem="Ami-Cg-fcA" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="h0M-ab-EGv"/>
<constraint firstItem="Ami-Cg-fcA" firstAttribute="top" secondItem="iN0-l3-epB" secondAttribute="top" id="zAN-VI-ZYk"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="roomAvatarView" destination="FJB-2F-rrQ" id="SUd-p8-VCH"/>
<outlet property="roomEncryptionBadgeView" destination="Mli-PC-WUh" id="MuX-Qw-DfQ"/>
<outlet property="roomNameLabel" destination="8lk-sN-3IP" id="wFm-R4-fBo"/>
<outlet property="titleLabel" destination="BnG-NU-7Mg" id="gDw-Pr-oR8"/>
<outlet property="titleLabelLeadingConstraint" destination="ES6-mL-Y9F" id="MpE-vt-KKC"/>
</connections>
<point key="canvasLocation" x="0.7246376811594204" y="-152.00892857142856"/>
</view>
</objects>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
+22 -2
View File
@@ -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;
@@ -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)
}
@@ -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)
}
+102 -24
View File
@@ -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
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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 }
}
@@ -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
}
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="V8j-Lb-PgC">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Thread List View Controller-->
<scene sceneID="mt5-wz-YKA">
<objects>
<viewController extendedLayoutIncludesOpaqueBars="YES" automaticallyAdjustsScrollViewInsets="NO" id="V8j-Lb-PgC" customClass="ThreadListViewController" customModule="Riot" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="EL9-GA-lwo">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="X8K-NO-SQ3">
<rect key="frame" x="0.0" y="44" width="414" height="852"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<connections>
<outlet property="dataSource" destination="V8j-Lb-PgC" id="FCQ-5E-AuZ"/>
<outlet property="delegate" destination="V8j-Lb-PgC" id="Kxs-vj-1RW"/>
</connections>
</tableView>
<view contentMode="scaleToFill" placeholderIntrinsicWidth="414" placeholderIntrinsicHeight="818" translatesAutoresizingMaskIntoConstraints="NO" id="7VY-m9-wCS" customClass="ThreadListEmptyView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="44" width="414" height="852"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<connections>
<outlet property="delegate" destination="V8j-Lb-PgC" id="SQT-2N-wnf"/>
</connections>
</view>
</subviews>
<viewLayoutGuide key="safeArea" id="bFg-jh-JZB"/>
<color key="backgroundColor" red="0.94509803921568625" green="0.96078431372549022" blue="0.97254901960784312" alpha="1" colorSpace="calibratedRGB"/>
<constraints>
<constraint firstAttribute="trailing" secondItem="7VY-m9-wCS" secondAttribute="trailing" id="JYi-uG-Yqc"/>
<constraint firstItem="X8K-NO-SQ3" firstAttribute="top" secondItem="bFg-jh-JZB" secondAttribute="top" id="Mfw-In-peq"/>
<constraint firstAttribute="bottom" secondItem="7VY-m9-wCS" secondAttribute="bottom" id="PkX-MO-5aO"/>
<constraint firstAttribute="bottom" secondItem="X8K-NO-SQ3" secondAttribute="bottom" id="SIG-7P-2CK"/>
<constraint firstItem="7VY-m9-wCS" firstAttribute="leading" secondItem="EL9-GA-lwo" secondAttribute="leading" id="acw-H6-LKX"/>
<constraint firstItem="X8K-NO-SQ3" firstAttribute="leading" secondItem="EL9-GA-lwo" secondAttribute="leading" id="ehI-Nc-6di"/>
<constraint firstAttribute="trailing" secondItem="X8K-NO-SQ3" secondAttribute="trailing" id="hbZ-R8-8kH"/>
<constraint firstItem="7VY-m9-wCS" firstAttribute="top" secondItem="bFg-jh-JZB" secondAttribute="top" id="obE-c9-v49"/>
</constraints>
</view>
<connections>
<outlet property="emptyView" destination="7VY-m9-wCS" id="Yny-Rn-edF"/>
<outlet property="threadsTableView" destination="X8K-NO-SQ3" id="owA-Uh-r9B"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="zK0-v6-7Wt" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-3198.5507246376815" y="-647.54464285714278"/>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -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))
}
}
@@ -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<MXKEventFormatterError>.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<MXKEventFormatterError>.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)
}
}
@@ -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
}
}
}
@@ -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)
}
@@ -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?
}
@@ -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
}
}
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="104" id="KGk-i7-Jjw" customClass="ThreadTableViewCell" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="104"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="104"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="I32-A5-WWw" customClass="UserAvatarView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="12" y="12" width="32" height="32"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="width" constant="32" id="f2R-6E-jRr"/>
<constraint firstAttribute="height" constant="32" id="uWM-eP-XnP"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="108-Xh-aZf">
<rect key="frame" x="56" y="12" width="200" height="17"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="Time" textAlignment="right" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="C2U-Ih-4Oh">
<rect key="frame" x="264" y="12" width="28" height="15"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Message" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xzR-f9-3qV">
<rect key="frame" x="56" y="33" width="236" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Md3-uq-cSB" customClass="ThreadSummaryView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="47" y="58" width="245" height="34"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</subviews>
<constraints>
<constraint firstItem="I32-A5-WWw" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="12" id="28p-b3-xMJ"/>
<constraint firstItem="108-Xh-aZf" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="12" id="2Dt-BH-xjF"/>
<constraint firstItem="Md3-uq-cSB" firstAttribute="top" secondItem="xzR-f9-3qV" secondAttribute="bottom" constant="8" id="6mB-Yd-Pyg"/>
<constraint firstAttribute="bottom" secondItem="Md3-uq-cSB" secondAttribute="bottom" constant="12" id="Ppd-HN-Ehg"/>
<constraint firstItem="I32-A5-WWw" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="12" id="Trt-CK-Tly"/>
<constraint firstItem="Md3-uq-cSB" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leading" constant="47" id="Vpf-02-TgV"/>
<constraint firstAttribute="trailing" secondItem="xzR-f9-3qV" secondAttribute="trailing" constant="28" id="Zz9-PK-l9b"/>
<constraint firstItem="C2U-Ih-4Oh" firstAttribute="leading" secondItem="108-Xh-aZf" secondAttribute="trailing" constant="8" id="bE8-Yy-3B9"/>
<constraint firstItem="xzR-f9-3qV" firstAttribute="leading" secondItem="I32-A5-WWw" secondAttribute="trailing" constant="12" id="g8i-lt-K8f"/>
<constraint firstItem="108-Xh-aZf" firstAttribute="leading" secondItem="I32-A5-WWw" secondAttribute="trailing" constant="12" id="sXf-FI-gD3"/>
<constraint firstItem="xzR-f9-3qV" firstAttribute="top" secondItem="108-Xh-aZf" secondAttribute="bottom" constant="4" id="tQN-Rr-MIS"/>
<constraint firstItem="C2U-Ih-4Oh" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="top" constant="12" id="u3s-nr-avO"/>
<constraint firstAttribute="trailing" secondItem="Md3-uq-cSB" secondAttribute="trailing" constant="28" id="vxt-vD-jy8"/>
<constraint firstAttribute="trailing" secondItem="C2U-Ih-4Oh" secondAttribute="trailing" constant="28" id="wNc-xV-uIR"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="lastMessageTimeLabel" destination="C2U-Ih-4Oh" id="pf3-df-T65"/>
<outlet property="rootMessageAvatarView" destination="I32-A5-WWw" id="zJW-QQ-jsG"/>
<outlet property="rootMessageContentLabel" destination="xzR-f9-3qV" id="97u-na-8XW"/>
<outlet property="rootMessageSenderLabel" destination="108-Xh-aZf" id="nUc-qK-UCD"/>
<outlet property="summaryView" destination="Md3-uq-cSB" id="3ye-77-1m6"/>
</connections>
<point key="canvasLocation" x="2.8985507246376816" y="129.91071428571428"/>
</tableViewCell>
</objects>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>
@@ -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
}
@@ -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
}
}
@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="ThreadListEmptyView" customModule="Riot" customModuleProvider="target">
<connections>
<outlet property="iconBackgroundView" destination="TO4-Bz-2iH" id="J0d-n5-T7m"/>
<outlet property="iconView" destination="96m-sr-xQJ" id="iKm-fe-wZ5"/>
<outlet property="infoLabel" destination="OE7-gq-abZ" id="9QP-LH-Wvh"/>
<outlet property="showAllThreadsButton" destination="uTW-xb-Z9y" id="phx-FN-Dn2"/>
<outlet property="tipLabel" destination="RyB-Ah-jey" id="Rh7-W2-EDU"/>
<outlet property="titleLabel" destination="0q6-zY-VZH" id="RCC-ZR-q4F"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB">
<rect key="frame" x="0.0" y="0.0" width="437" height="540"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" alignment="center" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="f50-47-PNo">
<rect key="frame" x="20" y="126" width="397" height="288"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="lm2-HJ-sTN">
<rect key="frame" x="78.5" y="0.0" width="240" height="1"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="1" id="SCQ-dJ-7RE"/>
</constraints>
</view>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TO4-Bz-2iH">
<rect key="frame" x="166.5" y="21" width="64" height="64"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="room_context_menu_reply_in_thread" translatesAutoresizingMaskIntoConstraints="NO" id="96m-sr-xQJ">
<rect key="frame" x="16" y="16" width="32" height="32"/>
<constraints>
<constraint firstAttribute="width" constant="32" id="K7U-U0-prN"/>
<constraint firstAttribute="width" secondItem="96m-sr-xQJ" secondAttribute="height" multiplier="1:1" id="Pgd-Qw-0Ju"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" white="0.66666666666666663" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" constant="64" id="3dO-ZF-YeI"/>
<constraint firstItem="96m-sr-xQJ" firstAttribute="centerY" secondItem="TO4-Bz-2iH" secondAttribute="centerY" id="EEK-EC-o8J"/>
<constraint firstAttribute="height" constant="64" id="iUz-gL-c7h"/>
<constraint firstItem="96m-sr-xQJ" firstAttribute="centerX" secondItem="TO4-Bz-2iH" secondAttribute="centerX" id="yqs-Ua-JWD"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="layer.cornerRadius">
<integer key="value" value="32"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Keep discussions organised with threads" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0q6-zY-VZH">
<rect key="frame" x="27.5" y="105" width="342.5" height="21.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="18"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Threads help keep your conversations on-topic and easy to track." textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="OE7-gq-abZ">
<rect key="frame" x="3" y="146.5" width="391.5" height="36"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Tip: Use “Thread” option when selecting a message." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="RyB-Ah-jey">
<rect key="frame" x="50.5" y="202.5" width="296.5" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="uTW-xb-Z9y">
<rect key="frame" x="142" y="237" width="113" height="30"/>
<state key="normal" title="Show all threads"/>
<connections>
<action selector="showAllThreadsButtonTapped:" destination="-1" eventType="touchUpInside" id="cX4-am-oWF"/>
</connections>
</button>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TKE-Zn-n2Q">
<rect key="frame" x="78.5" y="287" width="240" height="1"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="1" id="oXf-fF-mRt"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</stackView>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="f50-47-PNo" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="GG5-ib-fd0"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="f50-47-PNo" secondAttribute="trailing" constant="20" id="VsT-ay-7ZE"/>
<constraint firstItem="f50-47-PNo" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="20" id="gnS-Sc-jsF"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<point key="canvasLocation" x="-100.72463768115942" y="-9.375"/>
</view>
</objects>
<resources>
<image name="room_context_menu_reply_in_thread" width="18" height="18"/>
</resources>
</document>
@@ -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) {
}
}
@@ -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)
}
}
@@ -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())
}
}
@@ -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 }
}
+36
View File
@@ -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))"
}
}
+36 -6
View File
@@ -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")
+38 -2
View File
@@ -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)
}
}
+10
View File
@@ -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)
}
}
+26
View File
@@ -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
+1 -1
View File
@@ -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
}
@@ -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)
+5 -5
View File
@@ -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);
@@ -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))
@@ -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)
}
}
@@ -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
+9 -7
View File
@@ -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);
+1
View File
@@ -0,0 +1 @@
Permalinks: Create for thread events & handle navigations.
+1
View File
@@ -0,0 +1 @@
Search: Navigate to thread view for search results in threads.

Some files were not shown because too many files have changed in this diff Show More