Fixes #974 - Replace the timeline item contex menu with a bottom sheet

(+10 squashed commits)
[ba1d3160] Update timeline item action menu reaction UI
[410315ac] Move away from item bound action meus plus various tweaks following code review
[c25cd998] Add emoji reactions to the new timeline item action menu
[57001f49] Prevent timeline view layouts from dismissing the action menu
[d1e70538] Various UI tweaks
[652f4143] Switch to a long press gesture, move the header outside of the scroll view
[569a485c] Workaround timeline item action menu presentation state not being stored
[80c29567] Add currently selected item information in the TimelineItemMenu
[ff7790ec] Fixes #974 - Replace the timeline item contex menu with a bottom sheet
[ba1d3160] Rename TimelineItemContextMenu to TimelineIteMenu so that git correctly interprets it
This commit is contained in:
Stefan Ceriu
2023-05-30 13:53:25 +03:00
committed by Stefan Ceriu
parent 8ff5c6144c
commit 5a4f73e2b8
13 changed files with 382 additions and 185 deletions
@@ -51,7 +51,6 @@ enum RoomScreenViewAction {
case itemAppeared(id: String)
case itemDisappeared(id: String)
case itemTapped(id: String)
case itemDoubleTapped(id: String)
case linkClicked(url: URL)
case sendMessage
case sendReaction(key: String, eventID: String)
@@ -59,7 +58,11 @@ enum RoomScreenViewAction {
case cancelEdit
/// Mark the entire room as read - this is heavy handed as a starting point for now.
case markRoomAsRead
case contextMenuAction(itemID: String, action: TimelineItemContextMenuAction)
case timelineItemMenu(itemID: String)
case timelineItemMenuAction(itemID: String, action: TimelineItemMenuAction)
case displayEmojiPicker(itemID: String)
case displayCameraPicker
case displayMediaPicker
@@ -81,7 +84,7 @@ struct RoomScreenViewState: BindableState {
var bindings: RoomScreenViewStateBindings
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
var timelineItemMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemMenuActions?)?
var composerMode: RoomScreenComposerMode = .default
@@ -102,6 +105,16 @@ struct RoomScreenViewStateBindings {
var alertInfo: AlertInfo<RoomScreenErrorType>?
var debugInfo: TimelineItemDebugInfo?
var actionMenuInfo: TimelineItemActionMenuInfo?
}
struct TimelineItemActionMenuInfo: Identifiable {
let item: EventBasedTimelineItemProtocol
var id: String {
item.id
}
}
enum RoomScreenErrorType: Hashable {
@@ -67,12 +67,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
.store(in: &cancellables)
state.contextMenuActionProvider = { [weak self] itemId -> TimelineItemContextMenuActions? in
state.timelineItemMenuActionProvider = { [weak self] itemId -> TimelineItemMenuActions? in
guard let self else {
return nil
}
return self.contextMenuActionsForItemId(itemId)
return self.timelineItemMenuActionsForItemId(itemId)
}
roomProxy
@@ -109,8 +109,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task { await timelineController.processItemDisappearance(id) }
case .itemTapped(let id):
Task { await itemTapped(with: id) }
case .itemDoubleTapped(let id):
itemDoubleTapped(with: id)
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
@@ -124,8 +122,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.bindings.composerText = ""
case .markRoomAsRead:
Task { await markRoomAsRead() }
case .contextMenuAction(let itemID, let action):
processContentMenuAction(action, itemID: itemID)
case .timelineItemMenu(let itemID):
showTimelineItemActionMenu(for: itemID)
case .timelineItemMenuAction(let itemID, let action):
processTimelineItemMenuAction(action, itemID: itemID)
case .displayCameraPicker:
callback?(.displayCameraPicker)
case .displayMediaPicker:
@@ -136,6 +136,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
handlePasteOrDrop(provider)
case .tappedOnUser(userID: let userID):
Task { await handleTappedUser(userID: userID) }
case .displayEmojiPicker(let itemID):
guard let item = state.items.first(where: { $0.id == itemID }), item.isReactable else { return }
callback?(.displayEmojiPicker(itemID: itemID))
}
}
@@ -166,12 +170,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
state.showLoading = false
}
private func itemDoubleTapped(with itemId: String) {
guard let item = state.items.first(where: { $0.id == itemId }), item.isReactable else { return }
callback?(.displayEmojiPicker(itemID: itemId))
}
private func buildTimelineViews() {
var timelineViews = [RoomTimelineViewProvider]()
@@ -259,9 +258,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
}
// MARK: ContextMenus
// MARK: TimelineItemActionMenu
private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions? {
private func showTimelineItemActionMenu(for itemID: String) {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemID }),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
// Don't show a menu for non-event based items.
return
}
state.bindings.actionMenuInfo = .init(item: eventTimelineItem)
}
private func timelineItemMenuActionsForItemId(_ itemId: String) -> TimelineItemMenuActions? {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
let item = timelineItem as? EventBasedTimelineItemProtocol else {
// Don't show a context menu for non-event based items.
@@ -273,8 +282,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return nil
}
var actions: [TimelineItemContextMenuAction] = [
.react, .reply, .copyPermalink
var actions: [TimelineItemMenuAction] = [
.reply, .copyPermalink
]
if timelineItem is EventBasedMessageTimelineItemProtocol {
@@ -291,7 +300,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
actions.append(.report)
}
var debugActions: [TimelineItemContextMenuAction] = ServiceLocator.shared.settings.canShowDeveloperOptions ? [.viewSource] : []
var debugActions: [TimelineItemMenuAction] = ServiceLocator.shared.settings.canShowDeveloperOptions ? [.viewSource] : []
if let item = timelineItem as? EncryptedRoomTimelineItem,
case let .megolmV1AesSha2(sessionID) = item.encryptionType {
@@ -302,15 +311,13 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemID: String) {
private func processTimelineItemMenuAction(_ action: TimelineItemMenuAction, itemID: String) {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemID }),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
switch action {
case .react:
callback?(.displayEmojiPicker(itemID: eventTimelineItem.id))
case .copy:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else {
return
@@ -76,23 +76,10 @@ struct RoomAttachmentPicker: View {
var body: some View {
Label(title, systemImage: systemImageName)
.labelStyle(EqualIconWidthLabelStyle())
.labelStyle(FixedIconSizeLabelStyle())
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
private struct EqualIconWidthLabelStyle: LabelStyle {
@ScaledMetric private var menuIconSize = 24.0
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration
.icon
.frame(width: menuIconSize, height: menuIconSize)
configuration.title
}
}
}
@@ -39,6 +39,12 @@ struct RoomScreen: View {
.overlay { loadingIndicator }
.alert(item: $context.alertInfo) { $0.alert }
.sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $context.actionMenuInfo) { info in
context.viewState.timelineItemMenuActionProvider?(info.item.id).map { actions in
TimelineItemMenu(item: info.item, actions: actions)
.environmentObject(context)
}
}
.track(screen: .room)
.task(id: context.viewState.roomId) {
// Give a couple of seconds for items to load and to see them.
@@ -21,11 +21,15 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
@ScaledMetric private var senderNameVerticalPadding = 3
private let cornerRadius: CGFloat = 12
@State private var showItemActionMenu = false
private var isTextItem: Bool {
timelineItem is TextBasedRoomTimelineItem
@@ -98,10 +102,20 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
var messageBubble: some View {
styledContent
.contentShape(.contextMenuPreview, RoundedCornerShape(radius: cornerRadius, corners: roundedCorners)) // Rounded corners for the context menu animation.
.contextMenu {
context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in
TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions)
.onTapGesture(count: 2) {
context.send(viewAction: .displayEmojiPicker(itemID: timelineItem.id))
}
.onTapGesture {
context.send(viewAction: .itemTapped(id: timelineItem.id))
}
// We need a tap gesture before this long one so that it doesn't
// steal away the gestures from the scroll view
.onLongPressGesture(minimumDuration: 0.25) {
context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id))
feedbackGenerator.impactOccurred()
} onPressingChanged: { pressing in
if pressing {
feedbackGenerator.prepare()
}
}
.padding(.top, messageBubbleTopPadding)
@@ -21,8 +21,12 @@ struct TimelineItemPlainStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
@State private var showItemActionMenu = false
var body: some View {
VStack(alignment: .trailing) {
@@ -59,9 +63,20 @@ struct TimelineItemPlainStylerView<Content: View>: View {
content()
}
.contextMenu {
context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in
TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions)
.onTapGesture(count: 2) {
context.send(viewAction: .displayEmojiPicker(itemID: timelineItem.id))
}
.onTapGesture {
context.send(viewAction: .itemTapped(id: timelineItem.id))
}
// We need a tap gesture before this long one so that it doesn't
// steal away the gestures from the scroll view
.onLongPressGesture(minimumDuration: 0.25) {
context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id))
feedbackGenerator.impactOccurred()
} onPressingChanged: { pressing in
if pressing {
feedbackGenerator.prepare()
}
}
}
@@ -21,10 +21,10 @@ import SwiftUI
struct TimelineStyler<Content: View>: View {
@Environment(\.timelineStyle) private var style
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
var body: some View {
switch style {
case .plain:
@@ -1,122 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct TimelineItemContextMenuActions {
let actions: [TimelineItemContextMenuAction]
let debugActions: [TimelineItemContextMenuAction]
init?(actions: [TimelineItemContextMenuAction], debugActions: [TimelineItemContextMenuAction]) {
if actions.isEmpty, debugActions.isEmpty {
return nil
}
self.actions = actions
self.debugActions = debugActions
}
}
enum TimelineItemContextMenuAction: Identifiable, Hashable {
case react
case copy
case edit
case quote
case copyPermalink
case redact
case reply
case viewSource
case retryDecryption(sessionID: String)
case report
var id: Self { self }
var switchToDefaultComposer: Bool {
switch self {
case .reply, .edit:
return false
default:
return true
}
}
}
public struct TimelineItemContextMenu: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let itemID: String
let contextMenuActions: TimelineItemContextMenuActions
public var body: some View {
viewsForActions(contextMenuActions.actions)
Menu {
viewsForActions(contextMenuActions.debugActions)
} label: {
Label("Developer", systemImage: "hammer")
}
}
private func viewsForActions(_ actions: [TimelineItemContextMenuAction]) -> some View {
ForEach(actions, id: \.self) { action in
switch action {
case .react:
Button { send(action) } label: {
Label(L10n.commonReactions, systemImage: "face.smiling")
}
case .copy:
Button { send(action) } label: {
Label(L10n.actionCopy, systemImage: "doc.on.doc")
}
case .edit:
Button { send(action) } label: {
Label(L10n.actionEdit, systemImage: "pencil.line")
}
case .quote:
Button { send(action) } label: {
Label(L10n.actionQuote, systemImage: "quote.bubble")
}
case .copyPermalink:
Button { send(action) } label: {
Label(L10n.commonPermalink, systemImage: "link")
}
case .reply:
Button { send(action) } label: {
Label(L10n.actionReply, systemImage: "arrowshape.turn.up.left")
}
case .redact:
Button(role: .destructive) { send(action) } label: {
Label(L10n.actionRemove, systemImage: "trash")
}
case .viewSource:
Button { send(action) } label: {
Label(L10n.actionViewSource, systemImage: "doc.text.below.ecg")
}
case .retryDecryption:
Button { send(action) } label: {
Label(L10n.actionRetryDecryption, systemImage: "arrow.down.message")
}
case .report:
Button(role: .destructive) { send(action) } label: {
Label(L10n.actionReportContent, systemImage: "exclamationmark.bubble")
}
}
}
}
private func send(_ action: TimelineItemContextMenuAction) {
context.send(viewAction: .contextMenuAction(itemID: itemID, action: action))
}
}
@@ -0,0 +1,247 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct TimelineItemMenuActions {
let actions: [TimelineItemMenuAction]
let debugActions: [TimelineItemMenuAction]
init?(actions: [TimelineItemMenuAction], debugActions: [TimelineItemMenuAction]) {
if actions.isEmpty, debugActions.isEmpty {
return nil
}
self.actions = actions
self.debugActions = debugActions
}
}
enum TimelineItemMenuAction: Identifiable, Hashable {
case copy
case edit
case quote
case copyPermalink
case redact
case reply
case viewSource
case retryDecryption(sessionID: String)
case report
var id: Self { self }
var switchToDefaultComposer: Bool {
switch self {
case .reply, .edit:
return false
default:
return true
}
}
}
public struct TimelineItemMenu: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.presentationMode) private var presentationMode
let item: EventBasedTimelineItemProtocol
let actions: TimelineItemMenuActions
public var body: some View {
VStack {
header
.frame(idealWidth: 300.0)
Divider()
.background(Color.compound.bgSubtlePrimary)
ScrollView {
VStack(alignment: .leading, spacing: 0.0) {
reactionsSection
.padding(.top, 4.0)
.padding(.bottom, 8.0)
Divider()
.background(Color.compound.bgSubtlePrimary)
viewsForActions(actions.actions)
Divider()
.background(Color.compound.bgSubtlePrimary)
viewsForActions(actions.debugActions)
}
}
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.tint(.element.accent)
}
private var header: some View {
HStack(alignment: .top, spacing: 0.0) {
LoadableAvatarImage(url: item.sender.avatarURL,
name: item.sender.displayName,
contentID: item.sender.id,
avatarSize: .user(on: .timeline),
imageProvider: context.imageProvider)
Spacer(minLength: 8.0)
VStack(alignment: .leading) {
Text(item.sender.displayName ?? item.sender.id)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
Text(item.body.trimmingCharacters(in: .whitespacesAndNewlines))
.font(.compound.bodyMD)
.foregroundColor(.compound.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 16.0)
Text(item.timestamp)
.font(.compound.bodyXS)
.foregroundColor(.compound.textSecondary)
}
.padding(.horizontal)
.padding(.top, 32.0)
.padding(.bottom, 4.0)
}
private var reactionsSection: some View {
HStack(alignment: .center) {
reactionButton(for: "👍️")
reactionButton(for: "👎️")
reactionButton(for: "🔥")
reactionButton(for: "❤️")
reactionButton(for: "👏")
Button {
presentationMode.wrappedValue.dismiss()
// Otherwise we get errors that a sheet is already presented
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
context.send(viewAction: .displayEmojiPicker(itemID: item.id))
}
} label: {
Image(systemName: "plus.circle")
.font(.compound.headingLG)
}
}
.padding(.horizontal)
.frame(maxWidth: .infinity, alignment: .center)
}
private func reactionButton(for emoji: String) -> some View {
Button {
presentationMode.wrappedValue.dismiss()
context.send(viewAction: .sendReaction(key: emoji, eventID: item.id))
} label: {
Text(emoji)
.padding(8.0)
.font(.compound.headingLG)
.background(Circle()
.foregroundColor(reactionBackgroundColor(for: emoji)))
Spacer()
}
}
private func reactionBackgroundColor(for emoji: String) -> Color {
if item.properties.reactions.first(where: { $0.key == emoji }) != nil {
return .element.quinaryContent
} else {
return .clear
}
}
private func viewsForActions(_ actions: [TimelineItemMenuAction]) -> some View {
ForEach(actions, id: \.self) { action in
switch action {
case .copy:
Button { send(action) } label: {
MenuLabel(title: L10n.actionCopy, systemImageName: "doc.on.doc")
}
case .edit:
Button { send(action) } label: {
MenuLabel(title: L10n.actionEdit, systemImageName: "pencil.line")
}
case .quote:
Button { send(action) } label: {
MenuLabel(title: L10n.actionQuote, systemImageName: "quote.bubble")
}
case .copyPermalink:
Button { send(action) } label: {
MenuLabel(title: L10n.commonPermalink, systemImageName: "link")
}
case .reply:
Button { send(action) } label: {
MenuLabel(title: L10n.actionReply, systemImageName: "arrowshape.turn.up.left")
}
case .redact:
Button(role: .destructive) { send(action) } label: {
MenuLabel(title: L10n.actionRemove, systemImageName: "trash")
}
case .viewSource:
Button { send(action) } label: {
MenuLabel(title: L10n.actionViewSource, systemImageName: "doc.text.below.ecg")
}
case .retryDecryption:
Button { send(action) } label: {
MenuLabel(title: L10n.actionRetryDecryption, systemImageName: "arrow.down.message")
}
case .report:
Button(role: .destructive) { send(action) } label: {
MenuLabel(title: L10n.actionReportContent, systemImageName: "exclamationmark.bubble")
}
}
}
}
private func send(_ action: TimelineItemMenuAction) {
presentationMode.wrappedValue.dismiss()
context.send(viewAction: .timelineItemMenuAction(itemID: item.id, action: action))
}
private struct MenuLabel: View {
let title: String
let systemImageName: String
var body: some View {
Label(title, systemImage: systemImageName)
.labelStyle(FixedIconSizeLabelStyle())
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
struct TimelineItemMenu_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel.mock
static var previews: some View {
VStack {
if let item = RoomTimelineItemFixtures.singleMessageChunk.first as? EventBasedTimelineItemProtocol,
let actions = TimelineItemMenuActions(actions: [.copy, .edit, .reply, .redact], debugActions: [.viewSource]) {
TimelineItemMenu(item: item, actions: actions)
}
}
.environmentObject(viewModel.context)
}
}
@@ -70,7 +70,7 @@ class TimelineTableViewController: UIViewController {
}
}
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemMenuActions?)?
@Binding private var scrollToBottomButtonVisible: Bool
@@ -167,13 +167,15 @@ class TimelineTableViewController: UIViewController {
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
if tableView.frame.size != view.frame.size {
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
// Update the table's layout if necessary after the frame changed.
updateTopPadding()
guard tableView.frame.size != view.frame.size else {
return
}
tableView.frame = CGRect(origin: .zero, size: view.frame.size)
// Update the table's layout if necessary after the frame changed.
updateTopPadding()
if let previousLayout, previousLayout.isBottomVisible {
scrollToBottom(animated: false)
}
@@ -204,12 +206,6 @@ class TimelineTableViewController: UIViewController {
coordinator.send(viewAction: .linkClicked(url: url))
return .systemAction
})
.onTapGesture(count: 2) {
coordinator.send(viewAction: .itemDoubleTapped(id: timelineItem.id))
}
.onTapGesture {
coordinator.send(viewAction: .itemTapped(id: timelineItem.id))
}
}
.margins(.all, self.timelineStyle.rowInsets)
.minSize(height: 1)
@@ -70,7 +70,7 @@ struct TimelineView: UIViewControllerRepresentable {
}
// Doesn't have an equatable conformance :(
tableViewController.contextMenuActionProvider = context.viewState.contextMenuActionProvider
tableViewController.contextMenuActionProvider = context.viewState.timelineItemMenuActionProvider
}
func send(viewAction: RoomScreenViewAction) {