Implement message sending. Refactor the timeline. Fix various UI/UX issues and add scroll to bottom timeline button.

This commit is contained in:
Stefan Ceriu 2022-04-18 10:54:48 +03:00
parent 2e92653c9d
commit 7f1c24059f
34 changed files with 802 additions and 262 deletions

View File

@ -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 = "<group>"; };
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 = "<group>"; };
4E854E7CF531DAC5CBEBDC75 /* ListTableViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListTableViewAdapter.swift; sourceTree = "<group>"; };
4F49CDE349C490D617332770 /* NoticeRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineItem.swift; sourceTree = "<group>"; };
505208F28007C0FEC14E1FF0 /* HomeScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModelTests.swift; sourceTree = "<group>"; };
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = "<group>"; };
@ -221,6 +226,7 @@
7D0CBC76C80E04345E11F2DB /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = "<group>"; };
7DDBF99755A9008CF8C8499E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
7FFCC48E7F701B6C24484593 /* WeakDictionaryKeyReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakDictionaryKeyReference.swift; sourceTree = "<group>"; };
804F9B0FABE093C7284CD09B /* TimelineItemList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemList.swift; sourceTree = "<group>"; };
81B17DB1BC3B0C62AF84D230 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8210612D17A39369480FC183 /* MediaSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSource.swift; sourceTree = "<group>"; };
874A1842477895F199567BD7 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; };
@ -253,6 +259,7 @@
B8108C8F0ACF6A7EB72D0117 /* RoomScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenCoordinator.swift; sourceTree = "<group>"; };
B902EA6CD3296B0E10EE432B /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = "<group>"; };
BA97D630B74B0616C1468CBD /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreen.swift; sourceTree = "<group>"; };
BE6C10032A77AE7DC5AA4C50 /* MessageComposerTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposerTextField.swift; sourceTree = "<group>"; };
C024C151639C4E1B91FCC68B /* ElementXAttributeScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXAttributeScope.swift; sourceTree = "<group>"; };
C21ECC295F4DE8DAA86D62AC /* RoomSummaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummaryProtocol.swift; sourceTree = "<group>"; };
C2886615BEBAE33A0AA4D5F8 /* RoomScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenModels.swift; sourceTree = "<group>"; };
@ -270,6 +277,7 @@
DBFEAC3AC691CBB84983E275 /* ElementXTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementXTests.swift; sourceTree = "<group>"; };
DC54146B646F161762B54BBF /* ActivityPresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityPresentable.swift; sourceTree = "<group>"; };
E09C9DFFE9A897E439D770C5 /* ToastActivityPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastActivityPresenter.swift; sourceTree = "<group>"; };
E18CF12478983A5EB390FB26 /* MessageComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageComposer.swift; sourceTree = "<group>"; };
E5272BC4A60B6AD7553BACA1 /* BlurHashDecode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurHashDecode.swift; sourceTree = "<group>"; };
E8CA187FE656EE5A3F6C7DE5 /* UIFont+AttributedStringBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIFont+AttributedStringBuilder.m"; sourceTree = "<group>"; };
E8FD25EB4DF66625B74E4505 /* LoginScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenViewModel.swift; sourceTree = "<group>"; };
@ -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;

View File

@ -42,7 +42,7 @@
"location" : "https://github.com/matrix-org/matrix-rust-components-swift.git",
"state" : {
"branch" : "main",
"revision" : "f6682cf02eec087f921c53526895996cba169378"
"revision" : "bb19ced9e8889eb73e3989346d51992b82228a58"
}
},
{

View File

@ -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

View File

@ -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
}

View File

@ -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 = ""
}
}

View File

@ -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<Void, Never>()
let scrollViewDidReachTopPublisher = PassthroughSubject<Void, Never>()
let scrollViewBottomVisiblePublisher = PassthroughSubject<Bool, Never>()
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(())
}
}

View File

@ -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) { }
}
}
}

View File

@ -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<String>, 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<UITextViewWrapper>) -> 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<UITextViewWrapper>) {
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<CGFloat>, 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<String>
var calculatedHeight: Binding<CGFloat>
var isEditing: Binding<Bool>
let maxHeight: CGFloat
init(text: Binding<String>, height: Binding<CGFloat>, isEditing: Binding<Bool>, 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)
}
}
}

View File

@ -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")

View File

@ -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<Bool, Never>
let scrollToBottomPublisher: PassthroughSubject<Void, Never>
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())
}
}

View File

@ -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<Bool, Never>()
@State private var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
@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<Void, Never>()
let scrollViewDidReachTop = PassthroughSubject<Void, Never>()
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)
}
}

View File

@ -18,7 +18,7 @@
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Images/app-logo" translatesAutoresizingMaskIntoConstraints="NO" id="ue7-fB-5XS">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Images/appLogo" translatesAutoresizingMaskIntoConstraints="NO" id="ue7-fB-5XS">
<rect key="frame" x="87" y="328" width="240" height="240"/>
<color key="tintColor" red="0.050980392156862744" green="0.74117647058823533" blue="0.54509803921568623" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</imageView>
@ -37,7 +37,7 @@
</view>
</objects>
<resources>
<image name="Images/app-logo" width="240" height="240"/>
<image name="Images/appLogo" width="240" height="240"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>

View File

@ -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")

View File

@ -46,4 +46,8 @@ struct MockRoomProxy: RoomProxyProtocol {
func displayNameForUserId(_ userId: String, completion: @escaping (Result<String?, RoomProxyError>) -> Void) {
}
func sendMessage(_ message: String, callback: ((Result<Void, RoomProxyError>) -> Void)?) {
}
}

View File

@ -165,6 +165,25 @@ class RoomProxy: RoomProxyProtocol {
}
}
func sendMessage(_ message: String, callback: ((Result<Void, RoomProxyError>) -> 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) {

View File

@ -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, RoomProxyError>) -> Void)?)
func sendMessage(_ message: String, callback: ((Result<Void, RoomProxyError>) -> Void)?)
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}

View File

@ -30,4 +30,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func processItemDisappearance(_ itemId: String) {
}
func sendMessage(_ message: String) {
}
}

View File

@ -79,6 +79,10 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
func sendMessage(_ message: String) {
timelineProvider.sendMessage(message)
}
// MARK: - Private
@objc private func contentSizeCategoryDidChange() {

View File

@ -27,4 +27,6 @@ protocol RoomTimelineControllerProtocol {
func processItemAppearance(_ itemId: String)
func processItemDisappearance(_ itemId: String)
func sendMessage(_ message: String)
}

View File

@ -28,6 +28,10 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
}.store(in: &cancellables)
}
var messages: [RoomMessageProtocol] {
roomProxy.messages
}
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineError>) -> 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)")
}
}
}
}

View File

@ -23,4 +23,6 @@ protocol RoomTimelineProviderProtocol {
var messages: [RoomMessageProtocol] { get }
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineError>) -> Void)?)
func sendMessage(_ message: String)
}

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -8,6 +8,8 @@ fileGroups:
options:
groupSortPosition: bottom
createIntermediateGroups: true
deploymentTarget:
iOS: "15.0"
include:
- path: ElementX/SupportingFiles/target.yml