Message editing (#298)

* Add translation for editing mode

* Add `is_editable` flag to timeline item

* Add edit api implementation

* Add edit context menu action

* Add changelog

* Update Rust package

* Add isEditable into video timeline item

* Fix cyclomatic complexity

* Fix video item thumbnail loading

* Fix not updating timeline layout
This commit is contained in:
ismailgulek
2022-11-10 14:41:38 +03:00
committed by GitHub
parent 9f12b34a98
commit 3401d7a7e2
36 changed files with 189 additions and 31 deletions
@@ -22,6 +22,7 @@ enum RoomScreenViewModelAction { }
enum RoomScreenComposerMode: Equatable {
case `default`
case reply(id: String, displayName: String)
case edit(originalItemId: String)
}
enum RoomScreenViewAction {
@@ -32,6 +33,7 @@ enum RoomScreenViewAction {
case sendMessage
case sendReaction(key: String, eventID: String)
case cancelReply
case cancelEdit
}
struct RoomScreenViewState: BindableState {
@@ -106,6 +106,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
MXLog.warning("React with \(key) failed. Not implemented.")
case .cancelReply:
state.composerMode = .default
case .cancelEdit:
state.composerMode = .default
}
}
@@ -139,6 +141,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch currentComposerState {
case .reply(let itemId, _):
await timelineController.sendReply(currentMessage, to: itemId)
case .edit(let originalItemId):
await timelineController.editMessage(currentMessage, of: originalItemId)
default:
await timelineController.sendMessage(currentMessage)
}
@@ -163,15 +167,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
timelineItem is EventBasedTimelineItemProtocol else {
let item = timelineItem as? EventBasedTimelineItemProtocol else {
return .init(actions: [])
}
var actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink, .reply
]
if item.isEditable {
actions.append(.edit)
}
if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isOutgoing {
if item.isOutgoing {
actions.append(.redact)
}
@@ -187,6 +195,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch action {
case .copy:
UIPasteboard.general.string = item.text
case .edit:
state.bindings.composerFocused = true
state.bindings.composerText = item.text
state.composerMode = .edit(originalItemId: item.id)
case .quote:
state.bindings.composerFocused = true
state.bindings.composerText = "> \(item.text)"
@@ -208,10 +220,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription)
}
switch action {
case .reply:
break
default:
if action.switchToDefaultComposer {
state.composerMode = .default
}
}
@@ -24,15 +24,14 @@ struct MessageComposer: View {
let sendAction: () -> Void
let replyCancellationAction: () -> Void
let editCancellationAction: () -> Void
var body: some View {
let rect = RoundedRectangle(cornerRadius: borderRadius)
VStack(alignment: .leading, spacing: 4.0) {
if case let .reply(_, displayName) = type {
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
}
header
HStack(alignment: .center) {
MessageComposerTextField(placeholder: "Send a message",
MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder,
text: $text,
focused: $focused,
maxHeight: 300)
@@ -60,12 +59,24 @@ struct MessageComposer: View {
.clipShape(rect)
.animation(.elementDefault, value: type)
}
@ViewBuilder
private var header: some View {
switch type {
case .reply(_, let displayName):
MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction)
case .edit:
MessageComposerEditHeader(action: editCancellationAction)
case .default:
EmptyView()
}
}
private var borderRadius: CGFloat {
switch type {
case .default:
return 28.0
case .reply:
case .reply, .edit:
return 12.0
}
}
@@ -94,6 +105,28 @@ private struct MessageComposerReplyHeader: View {
}
}
private struct MessageComposerEditHeader: View {
let action: () -> Void
var body: some View {
HStack(alignment: .center) {
Label(ElementL10n.roomTimelineEditing, systemImage: "pencil")
.font(.element.caption2)
.foregroundColor(.element.secondaryContent)
.lineLimit(1)
Spacer()
Button {
action()
} label: {
Image(systemName: "x.circle")
.font(.element.callout)
.foregroundColor(.element.secondaryContent)
.padding(4.0)
}
}
}
}
struct MessageComposer_Previews: PreviewProvider {
static var previews: some View {
body.preferredColorScheme(.light)
@@ -108,7 +141,8 @@ struct MessageComposer_Previews: PreviewProvider {
sendingDisabled: true,
type: .default,
sendAction: { },
replyCancellationAction: { })
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant("Some message"),
focused: .constant(false),
@@ -116,7 +150,16 @@ struct MessageComposer_Previews: PreviewProvider {
type: .reply(id: UUID().uuidString,
displayName: "John Doe"),
sendAction: { },
replyCancellationAction: { })
replyCancellationAction: { },
editCancellationAction: { })
MessageComposer(text: .constant("Some message"),
focused: .constant(false),
sendingDisabled: false,
type: .edit(originalItemId: UUID().uuidString),
sendAction: { },
replyCancellationAction: { },
editCancellationAction: { })
}
.tint(.element.accent)
}
@@ -31,6 +31,8 @@ struct RoomScreen: View {
sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} editCancellationAction: {
context.send(viewAction: .cancelEdit)
}
.padding()
}
@@ -64,6 +64,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: senderId)
}
}
@@ -86,6 +86,7 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
inGroupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
senderId: senderId)
}
}
@@ -68,6 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
source: nil,
image: UIImage(systemName: "photo")))
@@ -77,6 +78,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
source: nil,
image: nil))
@@ -86,6 +88,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
source: nil,
image: nil,
@@ -64,6 +64,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: senderId)
}
}
@@ -52,6 +52,7 @@ struct RedactedRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: senderId)
}
}
@@ -73,6 +73,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
timestamp: timestamp,
inGroupState: .single,
isOutgoing: isOutgoing,
isEditable: isOutgoing,
senderId: senderId)
}
}
@@ -78,6 +78,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
duration: 21,
source: nil,
@@ -89,6 +90,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
duration: 22,
source: nil,
@@ -100,6 +102,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
timestamp: "Now",
inGroupState: .single,
isOutgoing: false,
isEditable: false,
senderId: "Bob",
duration: 23,
source: nil,
@@ -23,6 +23,7 @@ struct TimelineItemContextMenuActions {
enum TimelineItemContextMenuAction: Identifiable, Hashable {
case copy
case edit
case quote
case copyPermalink
case redact
@@ -30,6 +31,15 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable {
case viewSource
var id: Self { self }
var switchToDefaultComposer: Bool {
switch self {
case .reply, .edit:
return false
default:
return true
}
}
}
public struct TimelineItemContextMenu: View {
@@ -53,6 +63,10 @@ public struct TimelineItemContextMenu: View {
Button { callback(item) } label: {
Label(ElementL10n.actionCopy, systemImage: "doc.on.doc")
}
case .edit:
Button { callback(item) } label: {
Label(ElementL10n.edit, systemImage: "pencil")
}
case .quote:
Button { callback(item) } label: {
Label(ElementL10n.actionQuote, systemImage: "quote.bubble")
@@ -111,17 +111,17 @@ struct TimelineItemList: View {
.onReceive(scrollToBottomPublisher) {
scrollToBottom(animated: true)
}
.onChange(of: context.viewState.items.count) { _ in
guard !context.viewState.items.isEmpty,
context.viewState.items.count != timelineItems.count else {
return
.onChange(of: context.viewState.items) { items in
defer {
// update the items anyway
timelineItems = items
}
// Pin to the bottom if empty
if timelineItems.isEmpty {
if let lastItem = context.viewState.items.last {
if let lastItem = items.last {
let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false)
timelineItems = context.viewState.items
timelineItems = items
self.pinnedItem = pinnedItem
}
@@ -131,9 +131,9 @@ struct TimelineItemList: View {
// Pin to the new bottom if visible
if let currentLastItem = timelineItems.last,
visibleItemIdentifiers.contains(currentLastItem.id),
let newLastItem = context.viewState.items.last {
let newLastItem = items.last {
let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false)
timelineItems = context.viewState.items
timelineItems = items
self.pinnedItem = pinnedItem
return
@@ -143,20 +143,12 @@ struct TimelineItemList: View {
if let currentFirstItem = timelineItems.first,
visibleItemIdentifiers.contains(currentFirstItem.id) {
let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false)
timelineItems = context.viewState.items
timelineItems = items
self.pinnedItem = pinnedItem
return
}
// Otherwise just update the items
timelineItems = context.viewState.items
}
.onChange(of: context.viewState.items, perform: { items in
if timelineItems != items {
timelineItems = items
}
})
.background(GeometryReader { geo in
Color.clear.preference(key: ViewFramePreferenceKey.self, value: [geo.frame(in: .global)])
})