mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Fixes vector-im/element-x-ios/issues/114 - Replying to timeline items
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user