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