diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index d7ba56324..b129f4be8 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; }; 03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C2ABC1A9B62BDB3D216E7FD /* MemberDetailProviderManager.swift */; }; 059173B3C77056C406906B6D /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = D4DA544B2520BFA65D6DB4BB /* target.yml */; }; + 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */; }; 0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */; }; 1281625B25371BE53D36CB3A /* SeparatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1ED7E89865201EE7D53E6DA /* SeparatorRoomTimelineItem.swift */; }; 12F70C493FB69F4D7E9A37EA /* NavigationRouterStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D29EBCBFEC6FD0941749404D /* NavigationRouterStore.swift */; }; @@ -22,6 +23,7 @@ 20563476E4766B9C3035E461 /* ElementXUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A069578069541F94F2AF016C /* ElementXUITests.swift */; }; 224A55EEAEECF5336B14A4A5 /* EmoteRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2DF459F1737A594667CC46 /* EmoteRoomMessage.swift */; }; 22DADD537401E79D66132134 /* NavigationRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4173A48FD8542CD4AD3645C /* NavigationRouter.swift */; }; + 24906A1E82D0046655958536 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18CF12478983A5EB390FB26 /* MessageComposer.swift */; }; 277D2531C70F207A2F9F5906 /* KeychainControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 956BDA4AE16429AD015661A8 /* KeychainControllerProtocol.swift */; }; 29AEE68A604940180AB9EBFF /* MockRoomSummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BDAC8895AB2B77B47703AE /* MockRoomSummary.swift */; }; 2C0CE61E5DC177938618E0B1 /* RootRouterType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90733775209F4D4D366A268F /* RootRouterType.swift */; }; @@ -46,6 +48,7 @@ 418B4AEFD03DC7A6D2C9D5C8 /* EventBriefFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36322DD0D4E29D31B0945ADC /* EventBriefFactory.swift */; }; 46562110EE202E580A5FFD9C /* RoomScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93CF7B19FFCF8EFBE0A8696A /* RoomScreenViewModelTests.swift */; }; 49F2E7DD8CAACE09CEECE3E6 /* SeparatorRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */; }; + 4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */; }; 4E50727077B53D26A7C3E504 /* ElementXUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCEAA8205BCCCB8DBE01724 /* ElementXUITestsLaunchTests.swift */; }; 4E945AD6862C403F74E57755 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105B2A8426404EF66F00CFDB /* RoomTimelineItemFactory.swift */; }; 4ED453A61AF45EBE18D8BC69 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F77E8010D41AA3F5F9A1FCA /* NavigationModule.swift */; }; @@ -117,6 +120,7 @@ D0619D2E6B9C511190FBEB95 /* RoomMessageProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607974D08BD2AF83725D817A /* RoomMessageProtocol.swift */; }; D5EA4C6C80579279770D5804 /* ImageRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A45283CF1DB96E583BECA6 /* ImageRoomTimelineView.swift */; }; D735AF72894A273F53D941B8 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B33C10BC91ABFF09605DB6 /* Activity.swift */; }; + D826154612415D2A3BB6EBF3 /* ListTableViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */; }; DCB781BD227CA958809AFADF /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95CC95CD75B688E946438165 /* Coordinator.swift */; }; DD4ADDB73E0935B74D2D18D6 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3FE65EE63CBA65592863C2 /* UserSession.swift */; }; DDB80FD2753FEAAE43CC2AAE /* ImageRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A63815AD6A5C306453342F2 /* ImageRoomTimelineItem.swift */; }; @@ -192,6 +196,7 @@ 49EAD710A2C16EFF7C3EA16F /* Benchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Benchmark.swift; sourceTree = ""; }; 4CD6AC7546E8D7E5C73CEA48 /* ElementX.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = ElementX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4D6E4C37E9F0E53D3DF951AC /* HomeScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenUITests.swift; sourceTree = ""; }; + 4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTableViewAdapter.swift; sourceTree = ""; }; 4F49CDE349C490D617332770 /* NoticeRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItem.swift; sourceTree = ""; }; 505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = ""; }; 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = ""; }; @@ -221,6 +226,7 @@ 7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 7FFCC48E7F701B6C24484593 /* WeakDictionaryKeyReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionaryKeyReference.swift; sourceTree = ""; }; + 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = ""; }; 81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8210612D17A39369480FC183 /* MediaSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSource.swift; sourceTree = ""; }; 874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = ""; }; @@ -253,6 +259,7 @@ B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = ""; }; B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; BA97D630B74B0616C1468CBD /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = ""; }; + BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = ""; }; C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = ""; }; C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProtocol.swift; sourceTree = ""; }; C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = ""; }; @@ -270,6 +277,7 @@ DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = ""; }; DC54146B646F161762B54BBF /* ActivityPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityPresentable.swift; sourceTree = ""; }; E09C9DFFE9A897E439D770C5 /* ToastActivityPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastActivityPresenter.swift; sourceTree = ""; }; + E18CF12478983A5EB390FB26 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = ""; }; E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = ""; }; E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = ""; }; E8FD25EB4DF66625B74E4505 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = ""; }; @@ -529,7 +537,11 @@ 79023E5904B155E8E2B8B502 /* View */ = { isa = PBXGroup; children = ( + 4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */, + E18CF12478983A5EB390FB26 /* MessageComposer.swift */, + BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */, 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */, + 804F9B0FABE093C7284CD09B /* TimelineItemList.swift */, 874A1842477895F199567BD7 /* TimelineView.swift */, B7D3886505ECC85A06DA8258 /* Timeline */, ); @@ -990,6 +1002,7 @@ 2D8A687149E46B8C8B989561 /* KeychainController.swift in Sources */, 277D2531C70F207A2F9F5906 /* KeychainControllerProtocol.swift in Sources */, 539F448EC0250880703775DE /* LabelledActivityIndicatorView.swift in Sources */, + D826154612415D2A3BB6EBF3 /* ListTableViewAdapter.swift in Sources */, A941EAD7F407F2ED6DA54A31 /* LoginScreen.swift in Sources */, 306CC09DF101E7E9CDE79AA5 /* LoginScreenCoordinator.swift in Sources */, E9CEAF2C38E4E00459B811D9 /* LoginScreenModels.swift in Sources */, @@ -1003,6 +1016,8 @@ 03B8FEA668A5B76A93113BB1 /* MemberDetailProviderManager.swift in Sources */, 1999ECC6777752A2616775CF /* MemberDetailsProvider.swift in Sources */, A5EC21A071F58FC1229C20D0 /* MemberDetailsProviderProtocol.swift in Sources */, + 24906A1E82D0046655958536 /* MessageComposer.swift in Sources */, + 072BA9DBA932374CCA300125 /* MessageComposerTextField.swift in Sources */, 67E391A2E00709FB41903B36 /* MockMediaProvider.swift in Sources */, 51DB67C5B5BC68B0A6FF54D4 /* MockRoomProxy.swift in Sources */, 29AEE68A604940180AB9EBFF /* MockRoomSummary.swift in Sources */, @@ -1047,6 +1062,7 @@ D013E70C8E28E43497820444 /* TextRoomMessage.swift in Sources */, 7963F98CDFDEAC75E072BD81 /* TextRoomTimelineItem.swift in Sources */, 5E0F2E612718BB4397A6D40A /* TextRoomTimelineView.swift in Sources */, + 4D970CB606276717B43E2332 /* TimelineItemList.swift in Sources */, 500CB65ED116B81DA52FDAEE /* TimelineView.swift in Sources */, 15FEC447B7FEA62F418732AC /* ToastActivityPresenter.swift in Sources */, 0EE5EBA18BA1FE10254BB489 /* UIFont+AttributedStringBuilder.m in Sources */, @@ -1209,6 +1225,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1270,6 +1287,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1a1951fa2..179d2af84 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -42,7 +42,7 @@ "location" : "https://github.com/matrix-org/matrix-rust-components-swift.git", "state" : { "branch" : "main", - "revision" : "f6682cf02eec087f921c53526895996cba169378" + "revision" : "bb19ced9e8889eb73e3989346d51992b82228a58" } }, { diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index 566827f6b..795b0b465 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -26,7 +26,9 @@ internal enum Asset { internal static let elementGreen = ColorAsset(name: "Colors/ElementGreen") } internal enum Images { - internal static let appLogo = ImageAsset(name: "Images/app-logo") + internal static let appLogo = ImageAsset(name: "Images/appLogo") + internal static let timelineComposerSendMessage = ImageAsset(name: "Images/timelineComposerSendMessage") + internal static let timelineScrollToBottom = ImageAsset(name: "Images/timelineScrollToBottom") } } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index f57866f57..89e3b13db 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -25,10 +25,20 @@ enum RoomScreenViewAction { case itemAppeared(id: String) case itemDisappeared(id: String) case linkClicked(url: URL) + case sendMessage } struct RoomScreenViewState: BindableState { var roomTitle: String = "" var items: [RoomTimelineViewProvider] = [] var isBackPaginating = false + var bindings: RoomScreenViewStateBindings + + var sendButtonDisabled: Bool { + bindings.composerText.count == 0 + } +} + +struct RoomScreenViewStateBindings { + var composerText: String } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index d7cfff1f1..b2cec8630 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -38,7 +38,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol self.timelineController = timelineController self.timelineViewFactory = timelineViewFactory - super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥")) + super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥", bindings: RoomScreenViewStateBindings(composerText: ""))) timelineController.callbacks.sink { [weak self] callback in guard let self = self else { return } @@ -74,6 +74,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol timelineController.processItemDisappearance(id) case .linkClicked(let url): MXLog.warning("Link clicked: \(url)") + case .sendMessage: + guard state.bindings.composerText.count > 0 else { + return + } + + timelineController.sendMessage(state.bindings.composerText) + state.bindings.composerText = "" } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/ListTableViewAdapter.swift b/ElementX/Sources/Screens/RoomScreen/View/ListTableViewAdapter.swift new file mode 100644 index 000000000..47121cf44 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/ListTableViewAdapter.swift @@ -0,0 +1,221 @@ +// +// ListTableViewAdapter.swift +// ElementX +// +// Created by Stefan Ceriu on 15/04/2022. +// Copyright © 2022 element.io. All rights reserved. +// + +import UIKit +import Combine + +class ListTableViewAdapter: NSObject, UITableViewDelegate { + + private enum ContentOffsetDetails { + case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int) + case bottomOffset + } + + private let topDetectionOffset: CGFloat + private let bottomDetectionOffset: CGFloat + + private var contentOffsetObserverToken: NSKeyValueObservation? + private var boundsObserverToken: NSKeyValueObservation? + + private var isAtTop: Bool = false + private var isAtBottom: Bool = false + + private var offsetDetails: ContentOffsetDetails? + private var draggingInitiated = false + private var isAnimatingKeyboardAppearance = false + private var previousFrame: CGRect = .zero + + private(set) var tableView: UITableView? + + let scrollViewDidRestPublisher = PassthroughSubject() + let scrollViewDidReachTopPublisher = PassthroughSubject() + let scrollViewBottomVisiblePublisher = PassthroughSubject() + + override init() { + self.topDetectionOffset = 0.0 + self.bottomDetectionOffset = 0.0 + } + + init(tableView: UITableView, topDetectionOffset: CGFloat, bottomDetectionOffset: CGFloat) { + self.tableView = tableView + self.topDetectionOffset = topDetectionOffset + self.bottomDetectionOffset = bottomDetectionOffset + + super.init() + + tableView.keyboardDismissMode = .onDrag + + registerContentOfffsetObserver() + registerBoundsObserver() + + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(notification:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow(notification:)), name: UIResponder.keyboardDidShowNotification, object: nil) + + tableView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:))) + } + + func saveCurrentOffset() { + guard let tableView = tableView, + tableView.numberOfSections > 0 else { + return + } + + if isBottomVisible { + offsetDetails = .bottomOffset + } else if isTopVisible { + if let topIndexPath = tableView.indexPathsForVisibleRows?.first { + offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath, + previousItemCount: tableView.numberOfRows(inSection: 0)) + } + } + } + + func restoreSavedOffset() { + defer { + offsetDetails = nil + } + + guard let tableView = tableView, + tableView.numberOfSections > 0 else { + return + } + + let currentItemCount = tableView.numberOfRows(inSection: 0) + + switch offsetDetails { + case .bottomOffset: + tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false) + case .topOffset(let indexPath, let previousItemCount): + let row = indexPath.row + max(0, (currentItemCount - previousItemCount)) + if row < currentItemCount { + tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false) + } + case .none: + break + } + } + + var isTracking: Bool { + self.tableView?.isTracking == true + } + + var isDecelerating: Bool { + self.tableView?.isDecelerating == true + } + + var isTopVisible: Bool { + guard let scrollView = tableView else { + return false + } + + return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset + } + + var isBottomVisible: Bool { + guard let scrollView = tableView else { + return false + } + + return (scrollView.contentOffset.y + self.bottomDetectionOffset) >= (scrollView.contentSize.height - scrollView.frame.size.height) + } + + func scrollToBottom(animated: Bool = false) { + guard let tableView = tableView, + tableView.numberOfSections > 0 else { + return + } + + let currentItemCount = tableView.numberOfRows(inSection: 0) + guard currentItemCount > 1 else { + return + } + + tableView.scrollToRow(at: .init(row: currentItemCount - 1, section: 0), at: .bottom, animated: animated) + } + + // MARK: - Private + + private func registerContentOfffsetObserver() { + // Don't attempt stealing the UITableView delegate away from the List. + // Doing so results in undefined behavior e.g. context menus not working + contentOffsetObserverToken = tableView?.observe(\.contentOffset, options: .new, changeHandler: { [weak self] _, _ in + self?.handleScrollViewScroll() + }) + } + + private func deregisterContentOffsetObserver() { + contentOffsetObserverToken?.invalidate() + } + + private func registerBoundsObserver() { + boundsObserverToken = tableView?.observe(\.frame, options: .new, changeHandler: { [weak self] tableView, _ in + self?.previousFrame = tableView.frame + }) + } + + private func deregisterBoundsObserver() { + boundsObserverToken?.invalidate() + } + + @objc private func keyboardWillShow(notification: NSNotification) { + isAnimatingKeyboardAppearance = true + } + + @objc private func keyboardDidShow(notification: NSNotification) { + isAnimatingKeyboardAppearance = false + } + + private func handleScrollViewScroll() { + guard let tableView = self.tableView else { + return + } + + let hasScrolledBecauseOfFrameChange = (previousFrame != tableView.frame) + let shouldPinToBottom = isAtBottom && (isAnimatingKeyboardAppearance || hasScrolledBecauseOfFrameChange) + + if shouldPinToBottom { + deregisterContentOffsetObserver() + scrollToBottom() + DispatchQueue.main.async { + self.registerContentOfffsetObserver() + } + return + } + + let isTopVisible = self.isTopVisible + if isTopVisible && self.isAtTop != isTopVisible { + self.scrollViewDidReachTopPublisher.send(()) + } + self.isAtTop = isTopVisible + + let isBottomVisible = self.isBottomVisible + if self.isAtBottom != isBottomVisible { + self.scrollViewBottomVisiblePublisher.send(isBottomVisible) + self.isAtBottom = isBottomVisible + } + + if !self.draggingInitiated && tableView.isDragging { + self.draggingInitiated = true + } else if self.draggingInitiated && !tableView.isDragging { + self.draggingInitiated = false + self.scrollViewDidRestPublisher.send(()) + } + } + + @objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) { + guard let tableView = self.tableView, + sender.state == .ended, + draggingInitiated == true, + !tableView.isDecelerating else { + return + } + + self.draggingInitiated = false + self.scrollViewDidRestPublisher.send(()) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift new file mode 100644 index 000000000..acf425724 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift @@ -0,0 +1,47 @@ +// +// MessageComposer.swift +// ElementX +// +// Created by Stefan Ceriu on 15/04/2022. +// Copyright © 2022 element.io. All rights reserved. +// + +import SwiftUI + +struct MessageComposer: View { + + @Binding var text: String + var disabled: Bool + let action: () -> Void + + var body: some View { + HStack(alignment: .bottom) { + MessageComposerTextField(placeholder: "Send a message", text: $text, maxHeight: 300) + Button { + action() + } label: { + Image(uiImage: Asset.Images.timelineComposerSendMessage.image) + } + .padding(.bottom, 6.0) + .disabled(disabled) + .opacity(disabled ? 0.5 : 1.0) + .animation(.default, value: disabled) + .keyboardShortcut(.return, modifiers: [.command]) + } + } +} + +struct MessageComposer_Previews: PreviewProvider { + static var previews: some View { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: some View { + VStack { + MessageComposer(text: .constant(""), disabled: true) { } + MessageComposer(text: .constant("Some message"), disabled: false) { } + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift new file mode 100644 index 000000000..27a42529c --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift @@ -0,0 +1,182 @@ +// +// MessageComposerTextField.swift +// ElementX +// +// Created by Stefan Ceriu on 15/04/2022. +// Copyright © 2022 element.io. All rights reserved. +// + +import SwiftUI + +struct MessageComposerTextField: View { + + @Binding private var text: String + @State private var dynamicHeight: CGFloat = 100 + @State private var isEditing = false + + private let placeholder: String + private let maxHeight: CGFloat + + private var showingPlaceholder: Bool { + text.isEmpty + } + + init(placeholder: String, text: Binding, maxHeight: CGFloat) { + self.placeholder = placeholder + self._text = text + self.maxHeight = maxHeight + } + + private var placeholderColor: Color { + .gray + } + + private var borderColor: Color { + Color(uiColor: Asset.Colors.elementGreen.color) + } + + private var borderWidth: CGFloat { + return isEditing ? 2.0 : 1.0 + } + + var body: some View { + let rect = RoundedRectangle(cornerRadius: 8.0) + return UITextViewWrapper(text: $text, + calculatedHeight: $dynamicHeight, + isEditing: $isEditing, + maxHeight: maxHeight) + .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) + .padding(4.0) + .background(placeholderView, alignment: .topLeading) + .clipShape(rect) + .overlay(rect.stroke(borderColor, lineWidth: borderWidth)) + } + + @ViewBuilder + private var placeholderView: some View { + if showingPlaceholder { + Text(placeholder) + .foregroundColor(placeholderColor) + .padding(.leading, 8.0) + .padding(.top, 12.0) + } + } +} + +@available(iOS 14.0, *) +private struct UITextViewWrapper: UIViewRepresentable { + typealias UIViewType = UITextView + + @Binding var text: String + @Binding var calculatedHeight: CGFloat + @Binding var isEditing: Bool + + let maxHeight: CGFloat + + func makeUIView(context: UIViewRepresentableContext) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + + textView.isEditable = true + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.isSelectable = true + textView.isUserInteractionEnabled = true + textView.backgroundColor = UIColor.clear + textView.returnKeyType = .default + + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView + } + + func updateUIView(_ view: UITextView, context: UIViewRepresentableContext) { + if view.text != self.text { + view.text = self.text + } + + UITextViewWrapper.recalculateHeight(view: view, result: $calculatedHeight, maxHeight: maxHeight) + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: $text, + height: $calculatedHeight, + isEditing: $isEditing, + maxHeight: maxHeight) + } + + fileprivate static func recalculateHeight(view: UIView, result: Binding, maxHeight: CGFloat) { + let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude)) + + let height = min(maxHeight, newSize.height) + + if result.wrappedValue != height { + DispatchQueue.main.async { + result.wrappedValue = height // !! must be called asynchronously + } + } + } + + final class Coordinator: NSObject, UITextViewDelegate { + var text: Binding + var calculatedHeight: Binding + var isEditing: Binding + + let maxHeight: CGFloat + + init(text: Binding, height: Binding, isEditing: Binding, maxHeight: CGFloat) { + self.text = text + self.calculatedHeight = height + self.isEditing = isEditing + self.maxHeight = maxHeight + } + + func textViewDidChange(_ uiView: UITextView) { + text.wrappedValue = uiView.text + UITextViewWrapper.recalculateHeight(view: uiView, + result: calculatedHeight, + maxHeight: maxHeight) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + return true + } + + func textViewDidBeginEditing(_ textView: UITextView) { + isEditing.wrappedValue = true + } + + func textViewDidEndEditing(_ textView: UITextView) { + isEditing.wrappedValue = false + } + } +} + +struct MessageComposerTextField_Previews: PreviewProvider { + static var previews: some View { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: some View { + VStack { + PreviewWrapper() + PlaceholderPreviewWrapper() + } + } + + struct PreviewWrapper: View { + @State(initialValue: "123") var text: String + + var body: some View { + MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300) + } + } + + struct PlaceholderPreviewWrapper: View { + @State(initialValue: "") var text: String + + var body: some View { + MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300) + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 81487896b..a210711d5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -21,9 +21,23 @@ struct RoomScreen: View { @ObservedObject var context: RoomScreenViewModel.Context var body: some View { - TimelineView(context: context) - .navigationTitle(context.viewState.roomTitle) - .navigationBarTitleDisplayMode(.inline) + VStack(spacing: 0.0) { + TimelineView(context: context) + .navigationTitle(context.viewState.roomTitle) + .navigationBarTitleDisplayMode(.inline) + MessageComposer(text: $context.composerText, disabled: context.viewState.sendButtonDisabled) { + sendMessage() + } + .padding() + } + } + + private func sendMessage() { + guard !context.viewState.sendButtonDisabled else { + return + } + + context.send(viewAction: .sendMessage) } } @@ -31,6 +45,12 @@ struct RoomScreen: View { struct RoomScreen_Previews: PreviewProvider { static var previews: some View { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), timelineViewFactory: RoomTimelineViewFactory(), roomName: "Preview room") diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift new file mode 100644 index 000000000..8f2bc0763 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -0,0 +1,152 @@ +// +// TimelineItemList.swift +// ElementX +// +// Created by Stefan Ceriu on 15/04/2022. +// Copyright © 2022 element.io. All rights reserved. +// + +import SwiftUI +import Combine +import Introspect + +struct TimelineItemList: View { + + @State private var tableViewObserver: ListTableViewAdapter = ListTableViewAdapter() + @State private var timelineItems: [RoomTimelineViewProvider] = [] + @State private var hasPendingChanges = false + + @ObservedObject var context: RoomScreenViewModel.Context + + let bottomVisiblePublisher: PassthroughSubject + let scrollToBottomPublisher: PassthroughSubject + + var body: some View { + // The observer behaves differently when not in an reader + ScrollViewReader { _ in + List { + HStack { + Spacer() + ProgressView() + .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) + .animation(.default, value: context.viewState.isBackPaginating) + Spacer() + } + + // No idea why previews don't work otherwise + ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in + timelineItem + .listRowSeparator(.hidden) + .onAppear { + context.send(viewAction: .itemAppeared(id: timelineItem.id)) + } + .onDisappear { + context.send(viewAction: .itemDisappeared(id: timelineItem.id)) + } + .environment(\.openURL, OpenURLAction { url in + context.send(viewAction: .linkClicked(url: url)) + return .systemAction + }) + } + } + .listStyle(.plain) + .environment(\.defaultMinListRowHeight, 0.0) + .introspectTableView { tableView in + if tableView == tableViewObserver.tableView { + return + } + + tableViewObserver = ListTableViewAdapter(tableView: tableView, + topDetectionOffset: (tableView.bounds.size.height / 3.0), + bottomDetectionOffset: 10.0) + + tableViewObserver.scrollToBottom() + + // Check if there are enough items. Otherwise ask for more + attemptBackPagination() + } + .onAppear(perform: { + if timelineItems != context.viewState.items { + timelineItems = context.viewState.items + } + }) + .onReceive(scrollToBottomPublisher, perform: { + tableViewObserver.scrollToBottom(animated: true) + }) + .onReceive(tableViewObserver.scrollViewBottomVisiblePublisher, perform: { value in + bottomVisiblePublisher.send(value) + }) + .onReceive(tableViewObserver.scrollViewDidReachTopPublisher, perform: { + if context.viewState.isBackPaginating { + return + } + + attemptBackPagination() + }) + .onChange(of: context.viewState.items) { _ in + // Don't update the list while moving + if tableViewObserver.isDecelerating || tableViewObserver.isTracking { + hasPendingChanges = true + return + } + + tableViewObserver.saveCurrentOffset() + timelineItems = context.viewState.items + } + .onReceive(tableViewObserver.scrollViewDidRestPublisher, perform: { + if hasPendingChanges == false { + return + } + + tableViewObserver.saveCurrentOffset() + timelineItems = context.viewState.items + hasPendingChanges = false + }) + .onChange(of: timelineItems, perform: { _ in + tableViewObserver.restoreSavedOffset() + + // Check if there are enough items. Otherwise ask for more + attemptBackPagination() + }) + } + } + + func scrollToBottom(animated: Bool = false) { + tableViewObserver.scrollToBottom(animated: animated) + } + + private func attemptBackPagination() { + if context.viewState.isBackPaginating { + return + } + + if tableViewObserver.isTopVisible == false { + return + } + context.send(viewAction: .loadPreviousPage) + } + + private var isPreview: Bool { +#if DEBUG + return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" +#else + return false +#endif + } +} + +struct TimelineItemList_Previews: PreviewProvider { + static var previews: some View { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: some View { + let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + timelineViewFactory: RoomTimelineViewFactory(), + roomName: nil) + + TimelineItemList(context: viewModel.context, bottomVisiblePublisher: PassthroughSubject(), scrollToBottomPublisher: PassthroughSubject()) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 7f487a124..b864eee52 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -14,264 +14,48 @@ import Introspect struct TimelineView: View { - @State private var tableViewObserver: TableViewObserver = TableViewObserver() - @State private var timelineItems: [RoomTimelineViewProvider] = [] - @State private var hasPendingChanges = false - @State private var text: String = "" + @State private var bottomVisiblePublisher = PassthroughSubject() + @State private var scrollToBottomPublisher = PassthroughSubject() + @State private var scollToBottomButtonVisible = false @ObservedObject var context: RoomScreenViewModel.Context var body: some View { - // The observer behaves differently when not in an reader - ScrollViewReader { _ in - List { - HStack { - Spacer() - ProgressView() - .opacity(context.viewState.isBackPaginating ? 1.0 : 0.0) - .animation(.default, value: context.viewState.isBackPaginating) - Spacer() - } - - // No idea why previews don't work otherwise - ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in - timelineItem - .listRowSeparator(.hidden) - .onAppear { - context.send(viewAction: .itemAppeared(id: timelineItem.id)) - } - .onDisappear { - context.send(viewAction: .itemDisappeared(id: timelineItem.id)) - } - .environment(\.openURL, OpenURLAction { url in - context.send(viewAction: .linkClicked(url: url)) - return .systemAction - }) - } - } - .listStyle(.plain) - .environment(\.defaultMinListRowHeight, 0.0) - .introspectTableView { tableView in - if tableView == tableViewObserver.tableView { - return - } - - tableViewObserver = TableViewObserver(tableView: tableView, - topDetectionOffset: (tableView.bounds.size.height / 3.0)) - - tableViewObserver.scrollToBottom() - - // Check if there are enough items. Otherwise ask for more - attemptBackPagination() - } - .onAppear(perform: { - if timelineItems != context.viewState.items { - timelineItems = context.viewState.items - } - }) - .onReceive(tableViewObserver.scrollViewDidReachTop, perform: { - if context.viewState.isBackPaginating { - return - } - - attemptBackPagination() - }) - .onChange(of: context.viewState.items) { _ in - // Don't update the list while moving - if tableViewObserver.isDecelerating || tableViewObserver.isTracking { - hasPendingChanges = true - return - } - - tableViewObserver.saveCurrentOffset() - timelineItems = context.viewState.items - } - .onReceive(tableViewObserver.scrollViewDidRest, perform: { - if hasPendingChanges == false { - return - } - - tableViewObserver.saveCurrentOffset() - timelineItems = context.viewState.items - hasPendingChanges = false - }) - .onChange(of: timelineItems, perform: { _ in - tableViewObserver.restoreSavedOffset() - - // Check if there are enough items. Otherwise ask for more - attemptBackPagination() - }) + ZStack(alignment: .bottomTrailing) { + TimelineItemList(context: context, bottomVisiblePublisher: bottomVisiblePublisher, scrollToBottomPublisher: scrollToBottomPublisher) + scrollToBottomButton } } - private func attemptBackPagination() { - if context.viewState.isBackPaginating { - return - } - - if tableViewObserver.isTopVisible == false { - return - } - context.send(viewAction: .loadPreviousPage) - } - - private var isPreview: Bool { -#if DEBUG - return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" -#else - return false -#endif - } -} - -private class TableViewObserver: NSObject, UITableViewDelegate { - - private enum ContentOffsetDetails { - case topOffset(previousVisibleIndexPath: IndexPath, previousItemCount: Int) - case bottomOffset - } - - private let topDetectionOffset: CGFloat - - private var contentOffsetObserverToken: NSKeyValueObservation? - - private var isAtTop: Bool = false - private var offsetDetails: ContentOffsetDetails? - private var draggingInitiated = false - - private(set) var tableView: UITableView? - - let scrollViewDidRest = PassthroughSubject() - let scrollViewDidReachTop = PassthroughSubject() - - override init() { - self.topDetectionOffset = 0.0 - } - - init(tableView: UITableView, topDetectionOffset: CGFloat) { - self.tableView = tableView - self.topDetectionOffset = topDetectionOffset - super.init() - - // Don't attempt stealing the UITableView delegate away from the List. - // Doing so results in undefined behavior e.g. context menus not working - contentOffsetObserverToken = tableView.observe(\.contentOffset, options: .new, changeHandler: { [weak self] _, _ in - self?.handleScrollViewScroll() + @ViewBuilder + private var scrollToBottomButton: some View { + Button(action: { + scrollToBottomPublisher.send(()) + }, label: { + Image(uiImage: Asset.Images.timelineScrollToBottom.image) + .shadow(radius: 2.0) + .padding() }) - - tableView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture(_:))) - } - - func saveCurrentOffset() { - guard let tableView = tableView, - tableView.numberOfSections > 0 else { - return - } - - if isBottomVisible { - offsetDetails = .bottomOffset - } else if isTopVisible { - if let topIndexPath = tableView.indexPathsForVisibleRows?.first { - offsetDetails = .topOffset(previousVisibleIndexPath: topIndexPath, - previousItemCount: tableView.numberOfRows(inSection: 0)) - } - } - } - - func restoreSavedOffset() { - defer { - offsetDetails = nil - } - - guard let tableView = tableView, - tableView.numberOfSections > 0 else { - return - } - - let currentItemCount = tableView.numberOfRows(inSection: 0) - - switch offsetDetails { - case .bottomOffset: - tableView.scrollToRow(at: .init(row: max(0, currentItemCount - 1), section: 0), at: .bottom, animated: false) - case .topOffset(let indexPath, let previousItemCount): - let row = indexPath.row + max(0, (currentItemCount - previousItemCount)) - if row < currentItemCount { - tableView.scrollToRow(at: .init(row: row, section: 0), at: .top, animated: false) - } - case .none: - break - } - } - - var isTracking: Bool { - self.tableView?.isTracking == true - } - - var isDecelerating: Bool { - self.tableView?.isDecelerating == true - } - - var isTopVisible: Bool { - guard let scrollView = tableView else { - return false - } - - return (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) <= topDetectionOffset - } - - func scrollToBottom() { - guard let tableView = tableView, - tableView.numberOfSections > 0 else { - return - } - - let currentItemCount = tableView.numberOfRows(inSection: 0) - guard currentItemCount > 1 else { - return - } - - tableView.scrollToRow(at: .init(row: currentItemCount - 1, section: 0), at: .bottom, animated: false) - } - - // MARK: - Private - - private func handleScrollViewScroll() { - guard let tableView = self.tableView else { - return - } - - let isTopVisible = self.isTopVisible - if self.isTopVisible && self.isAtTop != isTopVisible { - self.scrollViewDidReachTop.send(()) - } - - self.isAtTop = isTopVisible - - if !self.draggingInitiated && tableView.isDragging { - self.draggingInitiated = true - } else if self.draggingInitiated && !tableView.isDragging { - self.draggingInitiated = false - self.scrollViewDidRest.send(()) - } - } - - @objc private func handlePanGesture(_ sender: UIPanGestureRecognizer) { - guard let tableView = self.tableView, - sender.state == .ended, - draggingInitiated == true, - !tableView.isDecelerating else { - return - } - - self.draggingInitiated = false - self.scrollViewDidRest.send(()) - } - - private var isBottomVisible: Bool { - guard let scrollView = tableView else { - return false - } - - return (scrollView.contentOffset.y) >= (scrollView.contentSize.height - scrollView.frame.size.height) + .onReceive(bottomVisiblePublisher, perform: { visible in + scollToBottomButtonVisible = !visible + }) + .opacity(scollToBottomButtonVisible ? 1.0 : 0.0) + .animation(.default, value: scollToBottomButtonVisible) + } +} + +struct TimelineView_Previews: PreviewProvider { + static var previews: some View { + body.preferredColorScheme(.light) + body.preferredColorScheme(.dark) + } + + @ViewBuilder + static var body: some View { + let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), + timelineViewFactory: RoomTimelineViewFactory(), + roomName: nil) + + TimelineView(context: viewModel.context) } } diff --git a/ElementX/Sources/Screens/Splash/SplashViewController.xib b/ElementX/Sources/Screens/Splash/SplashViewController.xib index 33d458e55..9fff87792 100644 --- a/ElementX/Sources/Screens/Splash/SplashViewController.xib +++ b/ElementX/Sources/Screens/Splash/SplashViewController.xib @@ -18,7 +18,7 @@ - + @@ -37,7 +37,7 @@ - + diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift index 8896af026..58b7f837c 100644 --- a/ElementX/Sources/Services/Media/MediaProvider.swift +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -44,7 +44,7 @@ struct MediaProvider: MediaProviderProtocol { processingQueue.async { do { - let imageData = try client.loadImage(source: source.underlyingSource) + let imageData = try client.getMediaContent(source: source.underlyingSource) guard let image = UIImage(data: Data(bytes: imageData, count: imageData.count)) else { MXLog.error("Invalid image data") diff --git a/ElementX/Sources/Services/Room/MockRoomProxy.swift b/ElementX/Sources/Services/Room/MockRoomProxy.swift index e720754ed..71569af68 100644 --- a/ElementX/Sources/Services/Room/MockRoomProxy.swift +++ b/ElementX/Sources/Services/Room/MockRoomProxy.swift @@ -46,4 +46,8 @@ struct MockRoomProxy: RoomProxyProtocol { func displayNameForUserId(_ userId: String, completion: @escaping (Result) -> Void) { } + + func sendMessage(_ message: String, callback: ((Result) -> Void)?) { + + } } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index f63dc6f64..ecdb369d3 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -165,6 +165,25 @@ class RoomProxy: RoomProxyProtocol { } } + func sendMessage(_ message: String, callback: ((Result) -> Void)?) { + let messageContent = messageEventContentFromMarkdown(md: message) + let transactionId = genTransactionId() + + messageProcessingQueue.async { + do { + try self.room.send(msg: messageContent, txnId: transactionId) + + DispatchQueue.main.async { + callback?(.success(())) + } + } catch { + DispatchQueue.main.async { + callback?(.failure(.failedSendingMessage)) + } + } + } + } + // MARK: - Private fileprivate func appendMessage(_ message: AnyMessage) { diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 58c7bab0b..eebf00828 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -14,6 +14,7 @@ enum RoomProxyError: Error { case backwardStreamNotAvailable case failedRetrievingMemberAvatarURL case failedRetrievingMemberDisplayName + case failedSendingMessage } enum RoomProxyCallback { @@ -43,5 +44,7 @@ protocol RoomProxyProtocol { func paginateBackwards(count: UInt, callback: ((Result) -> Void)?) + func sendMessage(_ message: String, callback: ((Result) -> Void)?) + var callbacks: PassthroughSubject { get } } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index 45e7d443e..9763bcccc 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -30,4 +30,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func processItemDisappearance(_ itemId: String) { } + + func sendMessage(_ message: String) { + + } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index 048b27be4..d9357a022 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -79,6 +79,10 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } + func sendMessage(_ message: String) { + timelineProvider.sendMessage(message) + } + // MARK: - Private @objc private func contentSizeCategoryDidChange() { diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift index d813eaf9a..7cc3d8b5f 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift @@ -27,4 +27,6 @@ protocol RoomTimelineControllerProtocol { func processItemAppearance(_ itemId: String) func processItemDisappearance(_ itemId: String) + + func sendMessage(_ message: String) } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index 416fdb07f..2d3d53e5e 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -28,6 +28,10 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { }.store(in: &cancellables) } + var messages: [RoomMessageProtocol] { + roomProxy.messages + } + func paginateBackwards(_ count: UInt, callback: ((Result) -> Void)?) { self.roomProxy.paginateBackwards(count: count) { result in switch result { @@ -39,7 +43,14 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { } } - var messages: [RoomMessageProtocol] { - roomProxy.messages + func sendMessage(_ message: String) { + roomProxy.sendMessage(message) { result in + switch result { + case .success: + break + case .failure(let error): + MXLog.error("Failed sending message with error: \(error)") + } + } } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift index 92fd933ab..ede71a352 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift @@ -23,4 +23,6 @@ protocol RoomTimelineProviderProtocol { var messages: [RoomMessageProtocol] { get } func paginateBackwards(_ count: UInt, callback: ((Result) -> Void)?) + + func sendMessage(_ message: String) } diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/Contents.json b/ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/Contents.json similarity index 100% rename from ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/Contents.json rename to ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/Contents.json diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/launch_screen_logo.png b/ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/launch_screen_logo.png similarity index 100% rename from ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/launch_screen_logo.png rename to ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/launch_screen_logo.png diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/launch_screen_logo@2x.png b/ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/launch_screen_logo@2x.png similarity index 100% rename from ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/launch_screen_logo@2x.png rename to ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/launch_screen_logo@2x.png diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/launch_screen_logo@3x.png b/ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/launch_screen_logo@3x.png similarity index 100% rename from ElementX/SupportingFiles/Assets.xcassets/Images/app-logo.imageset/launch_screen_logo@3x.png rename to ElementX/SupportingFiles/Assets.xcassets/Images/appLogo.imageset/launch_screen_logo@3x.png diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/Contents.json b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/Contents.json new file mode 100644 index 000000000..533ee1b36 --- /dev/null +++ b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "send_message_icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "send_message_icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "send_message_icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon.png b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon.png new file mode 100644 index 000000000..02dc65a0b Binary files /dev/null and b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon.png differ diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon@2x.png b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon@2x.png new file mode 100644 index 000000000..28b0183c6 Binary files /dev/null and b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon@2x.png differ diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon@3x.png b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon@3x.png new file mode 100644 index 000000000..4e3719595 Binary files /dev/null and b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineComposerSendMessage.imageset/send_message_icon@3x.png differ diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/Contents.json b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/Contents.json new file mode 100644 index 000000000..a223dee6c --- /dev/null +++ b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "timelineScrollToBottom.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "timelineScrollToBottom@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "timelineScrollToBottom@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom.png b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom.png new file mode 100644 index 000000000..992d1edaf Binary files /dev/null and b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom.png differ diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom@2x.png b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom@2x.png new file mode 100644 index 000000000..c21846293 Binary files /dev/null and b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom@2x.png differ diff --git a/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom@3x.png b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom@3x.png new file mode 100644 index 000000000..936e57707 Binary files /dev/null and b/ElementX/SupportingFiles/Assets.xcassets/Images/timelineScrollToBottom.imageset/timelineScrollToBottom@3x.png differ diff --git a/project.yml b/project.yml index 3748d182f..dd97a991e 100644 --- a/project.yml +++ b/project.yml @@ -8,6 +8,8 @@ fileGroups: options: groupSortPosition: bottom createIntermediateGroups: true + deploymentTarget: + iOS: "15.0" include: - path: ElementX/SupportingFiles/target.yml