diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 3da13a306..fa5c55400 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 9C698E30698EC59302A8EEBD /* NavigationStackCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackCoordinatorTests.swift; sourceTree = ""; }; 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 = ""; }; 9CE3C90E487B255B735D73C8 /* RoomScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenViewModel.swift; sourceTree = ""; }; 9E6D88E8AFFBF2C1D589C0FA /* UIConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIConstants.swift; sourceTree = ""; }; 9F85164F9475FF2867F71AAA /* RoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineController.swift; sourceTree = ""; }; @@ -1079,6 +1081,7 @@ A057F2FDC14866C3026A89A4 /* NotificationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerProtocol.swift; sourceTree = ""; }; A12D3B1BCF920880CA8BBB6B /* UserIndicatorControllerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserIndicatorControllerProtocol.swift; sourceTree = ""; }; A16CD2C62CB7DB78A4238485 /* ReportContentScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportContentScreenCoordinator.swift; sourceTree = ""; }; + A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemMenu.swift; sourceTree = ""; }; A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionVerificationStateMachineTests.swift; sourceTree = ""; }; A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryPicker.swift; sourceTree = ""; }; A2AC3C656E960E15B5905E05 /* UnsupportedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedRoomTimelineView.swift; sourceTree = ""; }; @@ -1127,7 +1130,6 @@ B2E7C987AE5DC9087BB19F7D /* MediaUploadPreviewScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenModels.swift; sourceTree = ""; }; B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenCoordinator.swift; sourceTree = ""; }; B43456E73F8A2D52B69B9FB9 /* TemplateScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreenViewModel.swift; sourceTree = ""; }; - B43AF03660F5FD4FFFA7F1CE /* TimelineItemContextMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemContextMenu.swift; sourceTree = ""; }; B590BD4507D4F0A377FDE01A /* LoadableAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableAvatarImage.swift; sourceTree = ""; }; B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeRoomTimelineView.swift; sourceTree = ""; }; B61C339A2FDDBD067FF6635C /* ConfettiScene.scn */ = {isa = PBXFileReference; path = ConfettiScene.scn; sourceTree = ""; }; @@ -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 */, diff --git a/ElementX/Sources/Other/SwiftUI/Layout/EqualIconWithLabelStyle.swift b/ElementX/Sources/Other/SwiftUI/Layout/EqualIconWithLabelStyle.swift new file mode 100644 index 000000000..aa3f288f7 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/Layout/EqualIconWithLabelStyle.swift @@ -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 + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index e67578802..b7729934f 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -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? var debugInfo: TimelineItemDebugInfo? + + var actionMenuInfo: TimelineItemActionMenuInfo? +} + +struct TimelineItemActionMenuInfo: Identifiable { + let item: EventBasedTimelineItemProtocol + + var id: String { + item.id + } } enum RoomScreenErrorType: Hashable { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 01eb1fce1..76371c007 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -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 diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomAttachmentPicker.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomAttachmentPicker.swift index 288562989..29d32bce5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomAttachmentPicker.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomAttachmentPicker.swift @@ -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 - } - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 53c0671ac..7b16085c2 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -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. diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index f3cd1133d..31345cc08 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -21,11 +21,15 @@ struct TimelineItemBubbledStylerView: 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: 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) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift index 9e678cafa..e278a11a6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemPlainStylerView.swift @@ -21,8 +21,12 @@ struct TimelineItemPlainStylerView: 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: 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() } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift index ad0440758..8323baa7f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineStyler.swift @@ -21,10 +21,10 @@ import SwiftUI struct TimelineStyler: View { @Environment(\.timelineStyle) private var style - + let timelineItem: EventBasedTimelineItemProtocol @ViewBuilder let content: () -> Content - + var body: some View { switch style { case .plain: diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift deleted file mode 100644 index 123fa8bda..000000000 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift +++ /dev/null @@ -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)) - } -} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift new file mode 100644 index 000000000..7e2db49aa --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift @@ -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) + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift index 9f9d7d5f0..69701f1f9 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineTableViewController.swift @@ -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) diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index bd091a8cf..9b1d5bd8d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -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) {