mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
Fixes vector-im/element-x-ios/issues/114 - Replying to timeline items
This commit is contained in:
parent
dd2385711a
commit
ffee192e89
@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
"room_timeline_permalink_creation_failure" = "Failed creating the permalink";
|
"room_timeline_permalink_creation_failure" = "Failed creating the permalink";
|
||||||
|
|
||||||
|
"room_timeline_replying_to" = "Replying to %@";
|
||||||
|
|
||||||
// MARK: - Authentication
|
// MARK: - Authentication
|
||||||
|
|
||||||
"authentication_login_title" = "Welcome back!";
|
"authentication_login_title" = "Welcome back!";
|
||||||
|
@ -24,6 +24,10 @@ extension ElementL10n {
|
|||||||
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
|
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
|
||||||
/// Failed creating the permalink
|
/// Failed creating the permalink
|
||||||
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure")
|
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
|
/// Bubbled Timeline
|
||||||
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
|
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
|
||||||
/// Plain Timeline
|
/// Plain Timeline
|
||||||
|
@ -24,6 +24,12 @@ enum TimelineItemContextMenuAction: Hashable {
|
|||||||
case quote
|
case quote
|
||||||
case copyPermalink
|
case copyPermalink
|
||||||
case redact
|
case redact
|
||||||
|
case reply
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RoomScreenComposerMode: Equatable {
|
||||||
|
case `default`
|
||||||
|
case reply(id: String, displayName: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RoomScreenViewAction {
|
enum RoomScreenViewAction {
|
||||||
@ -33,6 +39,7 @@ enum RoomScreenViewAction {
|
|||||||
case linkClicked(url: URL)
|
case linkClicked(url: URL)
|
||||||
case sendMessage
|
case sendMessage
|
||||||
case sendReaction(key: String, eventID: String)
|
case sendReaction(key: String, eventID: String)
|
||||||
|
case cancelReply
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoomScreenViewState: BindableState {
|
struct RoomScreenViewState: BindableState {
|
||||||
@ -45,6 +52,9 @@ struct RoomScreenViewState: BindableState {
|
|||||||
|
|
||||||
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
|
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
|
||||||
|
|
||||||
|
var composerMode: RoomScreenComposerMode = .default
|
||||||
|
|
||||||
|
var messageComposerDisabled = false // Remove this when we have local echoes
|
||||||
var sendButtonDisabled: Bool {
|
var sendButtonDisabled: Bool {
|
||||||
bindings.composerText.count == 0
|
bindings.composerText.count == 0
|
||||||
}
|
}
|
||||||
@ -52,6 +62,7 @@ struct RoomScreenViewState: BindableState {
|
|||||||
|
|
||||||
struct RoomScreenViewStateBindings {
|
struct RoomScreenViewStateBindings {
|
||||||
var composerText: String
|
var composerText: String
|
||||||
|
var composerFocused: Bool
|
||||||
|
|
||||||
/// Information describing the currently displayed alert.
|
/// Information describing the currently displayed alert.
|
||||||
var alertInfo: AlertInfo<RoomScreenErrorType>?
|
var alertInfo: AlertInfo<RoomScreenErrorType>?
|
||||||
|
@ -39,7 +39,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
|
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
|
||||||
roomAvatar: roomAvatar,
|
roomAvatar: roomAvatar,
|
||||||
roomEncryptionBadge: roomEncryptionBadge,
|
roomEncryptionBadge: roomEncryptionBadge,
|
||||||
bindings: .init(composerText: "")))
|
bindings: .init(composerText: "", composerFocused: false)))
|
||||||
|
|
||||||
timelineController.callbacks
|
timelineController.callbacks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
@ -75,7 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
default:
|
default:
|
||||||
state.isBackPaginating = false
|
state.isBackPaginating = false
|
||||||
}
|
}
|
||||||
|
|
||||||
case .itemAppeared(let id):
|
case .itemAppeared(let id):
|
||||||
await timelineController.processItemAppearance(id)
|
await timelineController.processItemAppearance(id)
|
||||||
case .itemDisappeared(let id):
|
case .itemDisappeared(let id):
|
||||||
@ -83,15 +82,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .linkClicked(let url):
|
case .linkClicked(let url):
|
||||||
MXLog.warning("Link clicked: \(url)")
|
MXLog.warning("Link clicked: \(url)")
|
||||||
case .sendMessage:
|
case .sendMessage:
|
||||||
guard state.bindings.composerText.count > 0 else {
|
await sendCurrentMessage()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await timelineController.sendMessage(state.bindings.composerText)
|
|
||||||
state.bindings.composerText = ""
|
|
||||||
case .sendReaction(let key, _):
|
case .sendReaction(let key, _):
|
||||||
#warning("Reaction implementation awaiting SDK support.")
|
#warning("Reaction implementation awaiting SDK support.")
|
||||||
MXLog.warning("React with \(key) failed. Not implemented.")
|
MXLog.warning("React with \(key) failed. Not implemented.")
|
||||||
|
case .cancelReply:
|
||||||
|
state.composerMode = .default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,6 +101,35 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
state.items = stateItems
|
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
|
// MARK: ContextMenus
|
||||||
|
|
||||||
private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
|
private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
|
||||||
@ -120,7 +145,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
|
|
||||||
let actions: [TimelineItemContextMenuAction] = [
|
let actions: [TimelineItemContextMenuAction] = [
|
||||||
.copy, .quote, .copyPermalink
|
.copy, .quote, .copyPermalink, .reply
|
||||||
]
|
]
|
||||||
|
|
||||||
#warning("Outgoing actions to be handled with the new Timeline API.")
|
#warning("Outgoing actions to be handled with the new Timeline API.")
|
||||||
@ -141,6 +166,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .copy:
|
case .copy:
|
||||||
UIPasteboard.general.string = item.text
|
UIPasteboard.general.string = item.text
|
||||||
case .quote:
|
case .quote:
|
||||||
|
state.bindings.composerFocused = true
|
||||||
state.bindings.composerText = "> \(item.text)"
|
state.bindings.composerText = "> \(item.text)"
|
||||||
case .copyPermalink:
|
case .copyPermalink:
|
||||||
do {
|
do {
|
||||||
@ -151,15 +177,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
case .redact:
|
case .redact:
|
||||||
redact(itemId)
|
redact(itemId)
|
||||||
|
case .reply:
|
||||||
|
state.bindings.composerFocused = true
|
||||||
|
state.composerMode = .reply(id: item.id, displayName: item.senderDisplayName ?? item.senderId)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
switch action {
|
||||||
private func displayError(_ type: RoomScreenErrorType) {
|
case .reply:
|
||||||
switch type {
|
break
|
||||||
case .alert(let message):
|
default:
|
||||||
state.bindings.alertInfo = AlertInfo(id: type,
|
state.composerMode = .default
|
||||||
title: ElementL10n.dialogTitleError,
|
|
||||||
message: message)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,28 +18,77 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MessageComposer: View {
|
struct MessageComposer: View {
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var disabled: Bool
|
@Binding var focused: Bool
|
||||||
let action: () -> Void
|
let sendingDisabled: Bool
|
||||||
|
let type: RoomScreenComposerMode
|
||||||
|
|
||||||
|
let sendAction: () -> Void
|
||||||
|
let replyCancellationAction: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .bottom) {
|
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 {
|
Button {
|
||||||
action()
|
sendAction()
|
||||||
} label: {
|
} label: {
|
||||||
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
|
Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
|
||||||
.background(Circle()
|
.background(Circle()
|
||||||
.foregroundColor(.global.white)
|
.foregroundColor(.global.white)
|
||||||
.padding(2)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 6.0)
|
.padding(.bottom, 6.0)
|
||||||
.disabled(disabled)
|
.disabled(sendingDisabled)
|
||||||
.opacity(disabled ? 0.5 : 1.0)
|
.opacity(sendingDisabled ? 0.5 : 1.0)
|
||||||
.animation(.elementDefault, value: disabled)
|
.animation(.elementDefault, value: sendingDisabled)
|
||||||
.keyboardShortcut(.return, modifiers: [.command])
|
.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 {
|
struct MessageComposer_Previews: PreviewProvider {
|
||||||
@ -51,8 +100,20 @@ struct MessageComposer_Previews: PreviewProvider {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
static var body: some View {
|
static var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
MessageComposer(text: .constant(""), disabled: true) { }
|
MessageComposer(text: .constant(""),
|
||||||
MessageComposer(text: .constant("Some message"), disabled: false) { }
|
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)
|
.tint(.element.accent)
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,9 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MessageComposerTextField: View {
|
struct MessageComposerTextField: View {
|
||||||
@Binding private var text: String
|
@Binding private var text: String
|
||||||
|
@Binding private var focused: Bool
|
||||||
|
|
||||||
@State private var dynamicHeight: CGFloat = 100
|
@State private var dynamicHeight: CGFloat = 100
|
||||||
@State private var isEditing = false
|
|
||||||
|
|
||||||
private let placeholder: String
|
private let placeholder: String
|
||||||
private let maxHeight: CGFloat
|
private let maxHeight: CGFloat
|
||||||
@ -28,36 +29,24 @@ struct MessageComposerTextField: View {
|
|||||||
text.isEmpty
|
text.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
init(placeholder: String, text: Binding<String>, maxHeight: CGFloat) {
|
init(placeholder: String, text: Binding<String>, focused: Binding<Bool>, maxHeight: CGFloat) {
|
||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
_text = text
|
_text = text
|
||||||
|
_focused = focused
|
||||||
self.maxHeight = maxHeight
|
self.maxHeight = maxHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
private var placeholderColor: Color {
|
private var placeholderColor: Color {
|
||||||
.gray
|
.element.secondaryContent
|
||||||
}
|
|
||||||
|
|
||||||
private var borderColor: Color {
|
|
||||||
.element.accent
|
|
||||||
}
|
|
||||||
|
|
||||||
private var borderWidth: CGFloat {
|
|
||||||
isEditing ? 2.0 : 1.0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let rect = RoundedRectangle(cornerRadius: 8.0)
|
UITextViewWrapper(text: $text,
|
||||||
return UITextViewWrapper(text: $text,
|
calculatedHeight: $dynamicHeight,
|
||||||
calculatedHeight: $dynamicHeight,
|
focused: $focused,
|
||||||
isEditing: $isEditing,
|
maxHeight: maxHeight)
|
||||||
maxHeight: maxHeight)
|
|
||||||
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
|
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
|
||||||
.padding(4.0)
|
|
||||||
.background(placeholderView, alignment: .topLeading)
|
.background(placeholderView, alignment: .topLeading)
|
||||||
.clipShape(rect)
|
|
||||||
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
|
|
||||||
.animation(.elementDefault, value: isEditing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -65,8 +54,6 @@ struct MessageComposerTextField: View {
|
|||||||
if showingPlaceholder {
|
if showingPlaceholder {
|
||||||
Text(placeholder)
|
Text(placeholder)
|
||||||
.foregroundColor(placeholderColor)
|
.foregroundColor(placeholderColor)
|
||||||
.padding(.leading, 8.0)
|
|
||||||
.padding(.top, 12.0)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -76,7 +63,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var calculatedHeight: CGFloat
|
@Binding var calculatedHeight: CGFloat
|
||||||
@Binding var isEditing: Bool
|
@Binding var focused: Bool
|
||||||
|
|
||||||
let maxHeight: CGFloat
|
let maxHeight: CGFloat
|
||||||
|
|
||||||
@ -90,23 +77,33 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
textView.isUserInteractionEnabled = true
|
textView.isUserInteractionEnabled = true
|
||||||
textView.backgroundColor = UIColor.clear
|
textView.backgroundColor = UIColor.clear
|
||||||
textView.returnKeyType = .default
|
textView.returnKeyType = .default
|
||||||
|
textView.textContainer.lineFragmentPadding = 0.0
|
||||||
|
textView.textContainerInset = .zero
|
||||||
|
|
||||||
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
|
|
||||||
return textView
|
return textView
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ view: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
|
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
|
||||||
if view.text != text {
|
if textView.text != text {
|
||||||
view.text = text
|
textView.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UITextViewWrapper.recalculateHeight(view: view, result: $calculatedHeight, maxHeight: maxHeight)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator(text: $text,
|
Coordinator(text: $text,
|
||||||
height: $calculatedHeight,
|
height: $calculatedHeight,
|
||||||
isEditing: $isEditing,
|
focused: $focused,
|
||||||
maxHeight: maxHeight)
|
maxHeight: maxHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,22 +114,22 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
|
|
||||||
if result.wrappedValue != height {
|
if result.wrappedValue != height {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
result.wrappedValue = height // !! must be called asynchronously
|
result.wrappedValue = height // Must be called asynchronously
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Coordinator: NSObject, UITextViewDelegate {
|
final class Coordinator: NSObject, UITextViewDelegate {
|
||||||
var text: Binding<String>
|
private var text: Binding<String>
|
||||||
var calculatedHeight: Binding<CGFloat>
|
private var calculatedHeight: Binding<CGFloat>
|
||||||
var isEditing: Binding<Bool>
|
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
|
self.text = text
|
||||||
calculatedHeight = height
|
calculatedHeight = height
|
||||||
self.isEditing = isEditing
|
self.focused = focused
|
||||||
self.maxHeight = maxHeight
|
self.maxHeight = maxHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,16 +140,12 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
|||||||
maxHeight: maxHeight)
|
maxHeight: maxHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
isEditing.wrappedValue = true
|
focused.wrappedValue = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func textViewDidEndEditing(_ textView: UITextView) {
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
isEditing.wrappedValue = false
|
focused.wrappedValue = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -173,13 +166,15 @@ struct MessageComposerTextField_Previews: PreviewProvider {
|
|||||||
|
|
||||||
struct PreviewWrapper: View {
|
struct PreviewWrapper: View {
|
||||||
@State var text: String
|
@State var text: String
|
||||||
|
@State var focused: Bool
|
||||||
|
|
||||||
init(text: String) {
|
init(text: String) {
|
||||||
_text = .init(initialValue: text)
|
_text = .init(initialValue: text)
|
||||||
|
_focused = .init(initialValue: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300)
|
MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,10 +24,16 @@ struct RoomScreen: View {
|
|||||||
TimelineView()
|
TimelineView()
|
||||||
.environmentObject(context)
|
.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()
|
sendMessage()
|
||||||
|
} replyCancellationAction: {
|
||||||
|
context.send(viewAction: .cancelReply)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -36,6 +36,10 @@ public struct TimelineItemContextMenu: View {
|
|||||||
Button { callback(item) } label: {
|
Button { callback(item) } label: {
|
||||||
Label(ElementL10n.permalink, systemImage: "link")
|
Label(ElementL10n.permalink, systemImage: "link")
|
||||||
}
|
}
|
||||||
|
case .reply:
|
||||||
|
Button { callback(item) } label: {
|
||||||
|
Label(ElementL10n.reply, systemImage: "arrow.uturn.left")
|
||||||
|
}
|
||||||
case .redact:
|
case .redact:
|
||||||
Button(role: .destructive) { callback(item) } label: {
|
Button(role: .destructive) { callback(item) } label: {
|
||||||
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")
|
Label(ElementL10n.messageActionItemRedact, systemImage: "trash")
|
||||||
|
@ -48,6 +48,7 @@ struct TimelineItemList: View {
|
|||||||
.contextMenu(menuItems: {
|
.contextMenu(menuItems: {
|
||||||
context.viewState.contextMenuBuilder?(timelineItem.id)
|
context.viewState.contextMenuBuilder?(timelineItem.id)
|
||||||
})
|
})
|
||||||
|
.opacity(opacityForItem(timelineItem))
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowInsets(settings.timelineStyle.listRowInsets)
|
.listRowInsets(settings.timelineStyle.listRowInsets)
|
||||||
@ -142,6 +143,14 @@ struct TimelineItemList: View {
|
|||||||
context.send(viewAction: .loadPreviousPage)
|
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 {
|
private var isPreview: Bool {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
|
||||||
|
@ -54,7 +54,7 @@ struct MockRoomProxy: RoomProxyProtocol {
|
|||||||
.failure(.backwardStreamNotAvailable)
|
.failure(.backwardStreamNotAvailable)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
|
func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
|
||||||
.failure(.failedSendingMessage)
|
.failure(.failedSendingMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -175,17 +175,24 @@ class RoomProxy: RoomProxyProtocol {
|
|||||||
.value
|
.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)
|
sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
|
||||||
defer {
|
defer {
|
||||||
sendMessageBgTask?.stop()
|
sendMessageBgTask?.stop()
|
||||||
}
|
}
|
||||||
let messageContent = messageEventContentFromMarkdown(md: message)
|
|
||||||
let transactionId = genTransactionId()
|
let transactionId = genTransactionId()
|
||||||
|
|
||||||
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in
|
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in
|
||||||
do {
|
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)
|
try self.room.send(msg: messageContent, txnId: transactionId)
|
||||||
|
// }
|
||||||
return .success(())
|
return .success(())
|
||||||
} catch {
|
} catch {
|
||||||
return .failure(.failedSendingMessage)
|
return .failure(.failedSendingMessage)
|
||||||
|
@ -55,9 +55,15 @@ protocol RoomProxyProtocol {
|
|||||||
|
|
||||||
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError>
|
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>
|
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
|
||||||
|
|
||||||
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
|
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension RoomProxyProtocol {
|
||||||
|
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
|
||||||
|
await sendMessage(message, inReplyToEventId: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -66,5 +66,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
|
|
||||||
func sendMessage(_ message: String) async { }
|
func sendMessage(_ message: String) async { }
|
||||||
|
|
||||||
|
func sendReply(_ message: String, to itemId: String) async { }
|
||||||
|
|
||||||
func redact(_ eventID: String) async { }
|
func redact(_ eventID: String) async { }
|
||||||
}
|
}
|
||||||
|
@ -104,13 +104,20 @@ 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 {
|
func redact(_ eventID: String) async {
|
||||||
switch await timelineProvider.redact(eventID) {
|
switch await timelineProvider.redact(eventID) {
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
@objc private func contentSizeCategoryDidChange() {
|
@objc private func contentSizeCategoryDidChange() {
|
||||||
|
@ -41,5 +41,7 @@ protocol RoomTimelineControllerProtocol {
|
|||||||
|
|
||||||
func sendMessage(_ message: String) async
|
func sendMessage(_ message: String) async
|
||||||
|
|
||||||
|
func sendReply(_ message: String, to itemId: String) async
|
||||||
|
|
||||||
func redact(_ eventID: String) async
|
func redact(_ eventID: String) async
|
||||||
}
|
}
|
||||||
|
@ -51,8 +51,8 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError> {
|
func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
|
||||||
switch await roomProxy.sendMessage(message) {
|
switch await roomProxy.sendMessage(message, inReplyToEventId: inReplyToItemId) {
|
||||||
case .success:
|
case .success:
|
||||||
return .success(())
|
return .success(())
|
||||||
case .failure:
|
case .failure:
|
||||||
|
@ -35,7 +35,13 @@ protocol RoomTimelineProviderProtocol {
|
|||||||
|
|
||||||
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>
|
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>
|
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
1
changelog.d/114.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Implemented timeline item repyling
|
Loading…
x
Reference in New Issue
Block a user