Fixes vector-im/element-x-ios/issues/114 - Replying to timeline items

This commit is contained in:
Stefan Ceriu 2022-09-14 16:21:23 +03:00 committed by GitHub
parent dd2385711a
commit ffee192e89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 229 additions and 79 deletions

View File

@ -13,6 +13,8 @@
"room_timeline_permalink_creation_failure" = "Failed creating the permalink";
"room_timeline_replying_to" = "Replying to %@";
// MARK: - Authentication
"authentication_login_title" = "Welcome back!";

View File

@ -24,6 +24,10 @@ extension ElementL10n {
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
/// Failed creating the permalink
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure")
/// Replying to %@
public static func roomTimelineReplyingTo(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1))
}
/// Bubbled Timeline
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
/// Plain Timeline

View File

@ -24,6 +24,12 @@ enum TimelineItemContextMenuAction: Hashable {
case quote
case copyPermalink
case redact
case reply
}
enum RoomScreenComposerMode: Equatable {
case `default`
case reply(id: String, displayName: String)
}
enum RoomScreenViewAction {
@ -33,6 +39,7 @@ enum RoomScreenViewAction {
case linkClicked(url: URL)
case sendMessage
case sendReaction(key: String, eventID: String)
case cancelReply
}
struct RoomScreenViewState: BindableState {
@ -45,6 +52,9 @@ struct RoomScreenViewState: BindableState {
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
var composerMode: RoomScreenComposerMode = .default
var messageComposerDisabled = false // Remove this when we have local echoes
var sendButtonDisabled: Bool {
bindings.composerText.count == 0
}
@ -52,6 +62,7 @@ struct RoomScreenViewState: BindableState {
struct RoomScreenViewStateBindings {
var composerText: String
var composerFocused: Bool
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomScreenErrorType>?

View File

@ -39,7 +39,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: roomAvatar,
roomEncryptionBadge: roomEncryptionBadge,
bindings: .init(composerText: "")))
bindings: .init(composerText: "", composerFocused: false)))
timelineController.callbacks
.receive(on: DispatchQueue.main)
@ -75,7 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
default:
state.isBackPaginating = false
}
case .itemAppeared(let id):
await timelineController.processItemAppearance(id)
case .itemDisappeared(let id):
@ -83,15 +82,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
guard state.bindings.composerText.count > 0 else {
return
}
await timelineController.sendMessage(state.bindings.composerText)
state.bindings.composerText = ""
await sendCurrentMessage()
case .sendReaction(let key, _):
#warning("Reaction implementation awaiting SDK support.")
MXLog.warning("React with \(key) failed. Not implemented.")
case .cancelReply:
state.composerMode = .default
}
}
@ -105,6 +101,35 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.items = stateItems
}
private func sendCurrentMessage() async {
guard !state.bindings.composerText.isEmpty else {
fatalError("This message should never be empty")
}
state.messageComposerDisabled = true
switch state.composerMode {
case .reply(let itemId, _):
await timelineController.sendReply(state.bindings.composerText, to: itemId)
default:
await timelineController.sendMessage(state.bindings.composerText)
}
state.bindings.composerText = ""
state.composerMode = .default
state.messageComposerDisabled = false
}
private func displayError(_ type: RoomScreenErrorType) {
switch type {
case .alert(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: ElementL10n.dialogTitleError,
message: message)
}
}
// MARK: ContextMenus
private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
@ -120,7 +145,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
let actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink
.copy, .quote, .copyPermalink, .reply
]
#warning("Outgoing actions to be handled with the new Timeline API.")
@ -141,6 +166,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .copy:
UIPasteboard.general.string = item.text
case .quote:
state.bindings.composerFocused = true
state.bindings.composerText = "> \(item.text)"
case .copyPermalink:
do {
@ -151,15 +177,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
case .redact:
redact(itemId)
case .reply:
state.bindings.composerFocused = true
state.composerMode = .reply(id: item.id, displayName: item.senderDisplayName ?? item.senderId)
}
}
private func displayError(_ type: RoomScreenErrorType) {
switch type {
case .alert(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: ElementL10n.dialogTitleError,
message: message)
switch action {
case .reply:
break
default:
state.composerMode = .default
}
}

View File

@ -18,28 +18,77 @@ import SwiftUI
struct MessageComposer: View {
@Binding var text: String
var disabled: Bool
let action: () -> Void
@Binding var focused: Bool
let sendingDisabled: Bool
let type: RoomScreenComposerMode
let sendAction: () -> Void
let replyCancellationAction: () -> Void
var body: some View {
HStack(alignment: .bottom) {
MessageComposerTextField(placeholder: "Send a message", text: $text, maxHeight: 300)
let rect = RoundedRectangle(cornerRadius: 8.0)
VStack(alignment: .leading, spacing: 2.0) {
if case let .reply(_, displayName) = type {
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
}
MessageComposerTextField(placeholder: "Send a message",
text: $text,
focused: $focused,
maxHeight: 300)
}
.padding(4.0)
.frame(minHeight: 44.0)
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
.animation(.elementDefault, value: type)
.animation(.elementDefault, value: borderWidth)
Button {
action()
sendAction()
} label: {
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
.background(Circle()
.foregroundColor(.global.white)
.padding(2)
)
}
.padding(.bottom, 6.0)
.disabled(disabled)
.opacity(disabled ? 0.5 : 1.0)
.animation(.elementDefault, value: disabled)
.disabled(sendingDisabled)
.opacity(sendingDisabled ? 0.5 : 1.0)
.animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command])
}
}
private var borderColor: Color {
.element.accent
}
private var borderWidth: CGFloat {
focused ? 2.0 : 1.0
}
}
private struct MessageComposerReplyHeader: View {
let displayName: String
let action: () -> Void
var body: some View {
HStack(alignment: .center) {
Label(ElementL10n.roomTimelineReplyingTo(displayName), systemImage: "arrow.uturn.left")
.font(.element.caption2)
.foregroundColor(.element.secondaryContent)
.lineLimit(1)
Spacer()
Button {
action()
} label: {
Image(systemName: "xmark")
.font(.element.caption2)
.padding(4.0)
}
}
}
}
struct MessageComposer_Previews: PreviewProvider {
@ -51,8 +100,20 @@ struct MessageComposer_Previews: PreviewProvider {
@ViewBuilder
static var body: some View {
VStack {
MessageComposer(text: .constant(""), disabled: true) { }
MessageComposer(text: .constant("Some message"), disabled: false) { }
MessageComposer(text: .constant(""),
focused: .constant(false),
sendingDisabled: true,
type: .default,
sendAction: { },
replyCancellationAction: { })
MessageComposer(text: .constant("Some message"),
focused: .constant(false),
sendingDisabled: false,
type: .reply(id: UUID().uuidString,
displayName: "John Doe"),
sendAction: { },
replyCancellationAction: { })
}
.tint(.element.accent)
}

View File

@ -18,8 +18,9 @@ import SwiftUI
struct MessageComposerTextField: View {
@Binding private var text: String
@Binding private var focused: Bool
@State private var dynamicHeight: CGFloat = 100
@State private var isEditing = false
private let placeholder: String
private let maxHeight: CGFloat
@ -28,36 +29,24 @@ struct MessageComposerTextField: View {
text.isEmpty
}
init(placeholder: String, text: Binding<String>, maxHeight: CGFloat) {
init(placeholder: String, text: Binding<String>, focused: Binding<Bool>, maxHeight: CGFloat) {
self.placeholder = placeholder
_text = text
_focused = focused
self.maxHeight = maxHeight
}
private var placeholderColor: Color {
.gray
}
private var borderColor: Color {
.element.accent
}
private var borderWidth: CGFloat {
isEditing ? 2.0 : 1.0
.element.secondaryContent
}
var body: some View {
let rect = RoundedRectangle(cornerRadius: 8.0)
return UITextViewWrapper(text: $text,
calculatedHeight: $dynamicHeight,
isEditing: $isEditing,
maxHeight: maxHeight)
UITextViewWrapper(text: $text,
calculatedHeight: $dynamicHeight,
focused: $focused,
maxHeight: maxHeight)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.padding(4.0)
.background(placeholderView, alignment: .topLeading)
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
.animation(.elementDefault, value: isEditing)
}
@ViewBuilder
@ -65,8 +54,6 @@ struct MessageComposerTextField: View {
if showingPlaceholder {
Text(placeholder)
.foregroundColor(placeholderColor)
.padding(.leading, 8.0)
.padding(.top, 12.0)
}
}
}
@ -76,7 +63,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
@Binding var text: String
@Binding var calculatedHeight: CGFloat
@Binding var isEditing: Bool
@Binding var focused: Bool
let maxHeight: CGFloat
@ -90,23 +77,33 @@ private struct UITextViewWrapper: UIViewRepresentable {
textView.isUserInteractionEnabled = true
textView.backgroundColor = UIColor.clear
textView.returnKeyType = .default
textView.textContainer.lineFragmentPadding = 0.0
textView.textContainerInset = .zero
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_ view: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if view.text != text {
view.text = text
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if textView.text != text {
textView.text = text
}
UITextViewWrapper.recalculateHeight(view: view, result: $calculatedHeight, maxHeight: maxHeight)
UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight)
if focused, textView.window != nil, !textView.isFirstResponder {
// Avoid cycle detected through attribute warnings
DispatchQueue.main.async {
textView.becomeFirstResponder()
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text,
height: $calculatedHeight,
isEditing: $isEditing,
focused: $focused,
maxHeight: maxHeight)
}
@ -117,22 +114,22 @@ private struct UITextViewWrapper: UIViewRepresentable {
if result.wrappedValue != height {
DispatchQueue.main.async {
result.wrappedValue = height // !! must be called asynchronously
result.wrappedValue = height // Must be called asynchronously
}
}
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
var calculatedHeight: Binding<CGFloat>
var isEditing: Binding<Bool>
private var text: Binding<String>
private var calculatedHeight: Binding<CGFloat>
private var focused: Binding<Bool>
let maxHeight: CGFloat
private let maxHeight: CGFloat
init(text: Binding<String>, height: Binding<CGFloat>, isEditing: Binding<Bool>, maxHeight: CGFloat) {
init(text: Binding<String>, height: Binding<CGFloat>, focused: Binding<Bool>, maxHeight: CGFloat) {
self.text = text
calculatedHeight = height
self.isEditing = isEditing
self.focused = focused
self.maxHeight = maxHeight
}
@ -143,16 +140,12 @@ private struct UITextViewWrapper: UIViewRepresentable {
maxHeight: maxHeight)
}
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
true
}
func textViewDidBeginEditing(_ textView: UITextView) {
isEditing.wrappedValue = true
focused.wrappedValue = true
}
func textViewDidEndEditing(_ textView: UITextView) {
isEditing.wrappedValue = false
focused.wrappedValue = false
}
}
}
@ -173,13 +166,15 @@ struct MessageComposerTextField_Previews: PreviewProvider {
struct PreviewWrapper: View {
@State var text: String
@State var focused: Bool
init(text: String) {
_text = .init(initialValue: text)
_focused = .init(initialValue: false)
}
var body: some View {
MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300)
MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300)
}
}
}

View File

@ -24,10 +24,16 @@ struct RoomScreen: View {
TimelineView()
.environmentObject(context)
MessageComposer(text: $context.composerText, disabled: context.viewState.sendButtonDisabled) {
MessageComposer(text: $context.composerText,
focused: $context.composerFocused,
sendingDisabled: context.viewState.sendButtonDisabled,
type: context.viewState.composerMode) {
sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
}
.padding()
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {

View File

@ -36,6 +36,10 @@ public struct TimelineItemContextMenu: View {
Button { callback(item) } label: {
Label(ElementL10n.permalink, systemImage: "link")
}
case .reply:
Button { callback(item) } label: {
Label(ElementL10n.reply, systemImage: "arrow.uturn.left")
}
case .redact:
Button(role: .destructive) { callback(item) } label: {
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")

View File

@ -48,6 +48,7 @@ struct TimelineItemList: View {
.contextMenu(menuItems: {
context.viewState.contextMenuBuilder?(timelineItem.id)
})
.opacity(opacityForItem(timelineItem))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(settings.timelineStyle.listRowInsets)
@ -142,6 +143,14 @@ struct TimelineItemList: View {
context.send(viewAction: .loadPreviousPage)
}
private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double {
guard case let .reply(selectedItemId, _) = context.viewState.composerMode else {
return 1.0
}
return selectedItemId == item.id ? 1.0 : 0.5
}
private var isPreview: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"

View File

@ -54,7 +54,7 @@ struct MockRoomProxy: RoomProxyProtocol {
.failure(.backwardStreamNotAvailable)
}
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMessage)
}

View File

@ -175,17 +175,24 @@ class RoomProxy: RoomProxyProtocol {
.value
}
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
defer {
sendMessageBgTask?.stop()
}
let messageContent = messageEventContentFromMarkdown(md: message)
let transactionId = genTransactionId()
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in
do {
// Disabled until available in Rust
// if let inReplyToEventId = inReplyToEventId {
// #warning("Markdown support when available in Ruma")
// try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId)
// } else {
let messageContent = messageEventContentFromMarkdown(md: message)
try self.room.send(msg: messageContent, txnId: transactionId)
// }
return .success(())
} catch {
return .failure(.failedSendingMessage)

View File

@ -55,9 +55,15 @@ protocol RoomProxyProtocol {
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError>
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError>
func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}
extension RoomProxyProtocol {
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
await sendMessage(message, inReplyToEventId: nil)
}
}

View File

@ -66,5 +66,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func sendMessage(_ message: String) async { }
func sendReply(_ message: String, to itemId: String) async { }
func redact(_ eventID: String) async { }
}

View File

@ -104,6 +104,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
}
func sendReply(_ message: String, to itemId: String) async {
switch await timelineProvider.sendMessage(message, inReplyToItemId: itemId) {
default:
break
}
}
func redact(_ eventID: String) async {
switch await timelineProvider.redact(eventID) {
default:

View File

@ -41,5 +41,7 @@ protocol RoomTimelineControllerProtocol {
func sendMessage(_ message: String) async
func sendReply(_ message: String, to itemId: String) async
func redact(_ eventID: String) async
}

View File

@ -51,8 +51,8 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
}
}
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError> {
switch await roomProxy.sendMessage(message) {
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
switch await roomProxy.sendMessage(message, inReplyToEventId: inReplyToItemId) {
case .success:
return .success(())
case .failure:

View File

@ -35,7 +35,13 @@ protocol RoomTimelineProviderProtocol {
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError>
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError>
func redact(_ eventID: String) async -> Result<Void, RoomTimelineProviderError>
}
extension RoomTimelineProviderProtocol {
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError> {
await sendMessage(message, inReplyToItemId: nil)
}
}

1
changelog.d/114.feature Normal file
View File

@ -0,0 +1 @@
Implemented timeline item repyling