mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
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:
parent
8ff5c6144c
commit
5a4f73e2b8
@ -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 */,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,11 +171,6 @@ 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,12 +21,16 @@ 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,9 +21,13 @@ 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) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
247
ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift
Normal file
247
ElementX/Sources/Screens/RoomScreen/View/TimelineItemMenu.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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,12 +167,14 @@ class TimelineTableViewController: UIViewController {
|
||||
override func viewWillLayoutSubviews() {
|
||||
super.viewWillLayoutSubviews()
|
||||
|
||||
if tableView.frame.size != view.frame.size {
|
||||
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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user