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

View File

@ -8,7 +8,6 @@
/* Begin PBXBuildFile section */
0033481EE363E4914295F188 /* LocalizationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C070FD43DC6BF4E50217965A /* LocalizationTests.swift */; };
01CB8ACFA5E143E89C168CA8 /* TimelineItemContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */; };
020F7E70167FB2833266F2F0 /* AnalyticsSettingsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D39D7F513A36C9C1951DB44C /* AnalyticsSettingsScreen.swift */; };
02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 218AB05B4E3889731959C5F1 /* EventBasedTimelineItemProtocol.swift */; };
037006FB6DF1374F94E4058D /* Dictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDCAC6CAAD65A2C24EA9C4B /* Dictionary.swift */; };
@ -205,6 +204,7 @@
54C774874BED4A8FAD1F22FE /* AnalyticsConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77B3D4950F1707E66E4A45A /* AnalyticsConfiguration.swift */; };
564BF06B3E93D6DD55F903B2 /* CreateRoomCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C618CA2B6C8758B06C88013C /* CreateRoomCoordinator.swift */; };
565868808A1DA565707394ED /* CurrentValuePublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 127C8472672A5BA09EF1ACF8 /* CurrentValuePublisher.swift */; };
56B253546E15DE3E961A4C74 /* EqualIconWithLabelStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C993E02E4A29204F1510D7A /* EqualIconWithLabelStyle.swift */; };
56F0A22972A3BB519DA2261C /* HomeScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F5530B2212862FA4BEFF2D /* HomeScreenViewModelProtocol.swift */; };
5770C4906668C6D3008A2AC9 /* SessionVerificationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5046BB295AEAFA6FB81655 /* SessionVerificationScreenModels.swift */; };
5780E444F405AA1304E1C23E /* DeveloperOptionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */; };
@ -322,6 +322,7 @@
84EFCB95F9DA2979C8042B26 /* UITestsSignalling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0192CE2F891141A25B49F /* UITestsSignalling.swift */; };
85813D87DDD7F67A46BD9AF7 /* ImageProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E8A8047B50E3607ACD354E /* ImageProviderProtocol.swift */; };
858276B19C7C0AD4CA98EA78 /* portrait_test_image.jpg in Resources */ = {isa = PBXBuildFile; fileRef = AF042B0FB2EE88977C91E330 /* portrait_test_image.jpg */; };
858C04B62166B5BAFCD20F2D /* TimelineItemMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */; };
85AFBB433AD56704A880F8A0 /* FramePreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */; };
85F89F3F320F4FADCFFFE68B /* ServerSelectionScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */; };
864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */; };
@ -1071,6 +1072,7 @@
9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = "<group>"; };
9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackCoordinatorTests.swift; sourceTree = "<group>"; };
9C7F7DE62D33C6A26CBFCD72 /* IntegrationTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = IntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9C993E02E4A29204F1510D7A /* EqualIconWithLabelStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualIconWithLabelStyle.swift; sourceTree = "<group>"; };
9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModel.swift; sourceTree = "<group>"; };
9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIConstants.swift; sourceTree = "<group>"; };
9F85164F9475FF2867F71AAA /* RoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineController.swift; sourceTree = "<group>"; };
@ -1079,6 +1081,7 @@
A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = "<group>"; };
A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = "<group>"; };
A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = "<group>"; };
A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = "<group>"; };
A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationStateMachineTests.swift; sourceTree = "<group>"; };
A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryPicker.swift; sourceTree = "<group>"; };
A2AC3C656E960E15B5905E05 /* UnsupportedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineView.swift; sourceTree = "<group>"; };
@ -1127,7 +1130,6 @@
B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenModels.swift; sourceTree = "<group>"; };
B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenCoordinator.swift; sourceTree = "<group>"; };
B43456E73F8A2D52B69B9FB9 /* TemplateScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModel.swift; sourceTree = "<group>"; };
B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemContextMenu.swift; sourceTree = "<group>"; };
B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = "<group>"; };
B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = "<group>"; };
B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; path = ConfettiScene.scn; sourceTree = "<group>"; };
@ -2369,8 +2371,8 @@
AA85B02533375D19744EAA46 /* RoomAttachmentPicker.swift */,
422724361B6555364C43281E /* RoomHeaderView.swift */,
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */,
B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */,
7023EB4F3B7C7D1FBA68638B /* TimelineItemDebugView.swift */,
A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */,
0BC588051E6572A1AF51D738 /* TimelineSenderAvatarView.swift */,
F9212AE02CBDD692C56A879F /* TimelineTableViewController.swift */,
874A1842477895F199567BD7 /* TimelineView.swift */,
@ -2971,6 +2973,7 @@
isa = PBXGroup;
children = (
8872E9C5E91E9F2BFC4EBCCA /* AlignedScrollView.swift */,
9C993E02E4A29204F1510D7A /* EqualIconWithLabelStyle.swift */,
4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */,
565F1B2B300597C616B37888 /* FullscreenDialog.swift */,
398817652FA8ABAE0A31AC6D /* ReadableFrameModifier.swift */,
@ -3875,6 +3878,7 @@
8B1D5CE017EEC734CF5FE130 /* Encodable.swift in Sources */,
4C5A638DAA8AF64565BA4866 /* EncryptedRoomTimelineItem.swift in Sources */,
B5903E48CF43259836BF2DBF /* EncryptedRoomTimelineView.swift in Sources */,
56B253546E15DE3E961A4C74 /* EqualIconWithLabelStyle.swift in Sources */,
F78BAD28482A467287A9A5A3 /* EventBasedMessageTimelineItemProtocol.swift in Sources */,
02D8DF8EB7537EB4E9019DDB /* EventBasedTimelineItemProtocol.swift in Sources */,
63E46D18B91D08E15FC04125 /* ExpiringTaskRunner.swift in Sources */,
@ -4136,8 +4140,8 @@
5D2AF8C0DF872E7985F8FE54 /* TimelineDeliveryStatusView.swift in Sources */,
84C0CF78BCE085C08CB94D86 /* TimelineEventProxy.swift in Sources */,
157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */,
01CB8ACFA5E143E89C168CA8 /* TimelineItemContextMenu.swift in Sources */,
FBCCF1EA25A071324FCD8544 /* TimelineItemDebugView.swift in Sources */,
858C04B62166B5BAFCD20F2D /* TimelineItemMenu.swift in Sources */,
F508683B76EF7B23BB2CBD6D /* TimelineItemPlainStylerView.swift in Sources */,
440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */,
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */,

View File

@ -0,0 +1,30 @@
//
// Copyright 2023 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 FixedIconSizeLabelStyle: LabelStyle {
@ScaledMetric private var iconSize = 24.0
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration
.icon
.frame(width: iconSize, height: iconSize)
configuration.title
}
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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.

View File

@ -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)

View File

@ -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()
}
}
}

View File

@ -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:

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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) {