From 31e9a22b726c59ac5e7cd6b9c6572cd5e1de8731 Mon Sep 17 00:00:00 2001 From: Sjmarf <78750526+Sjmarf@users.noreply.github.com> Date: Fri, 3 Jan 2025 20:16:48 +0000 Subject: [PATCH] Edit Private Messages (#1567) --- Mlem/App/Configuration/Icons.swift | 1 + .../Globals/Definitions/ErrorsTracker.swift | 4 +- .../Message1Providing+Extensions.swift | 14 ++ .../MessageFeedView/MessageBubbleView.swift | 3 +- .../MessageFeedView+Logic.swift | 37 +++++ .../MessageFeedView/MessageFeedView.swift | 135 +++++++++++------- .../Tabs/Settings/DeveloperSettingsView.swift | 20 +-- .../App/Views/Shared/MarkdownTextEditor.swift | 1 + Mlem/App/Views/Shared/MessageView.swift | 21 ++- .../Navigation/NavigationPage+View.swift | 4 +- .../Shared/Navigation/NavigationPage.swift | 26 +++- Mlem/Localizable.xcstrings | 3 + 12 files changed, 193 insertions(+), 76 deletions(-) diff --git a/Mlem/App/Configuration/Icons.swift b/Mlem/App/Configuration/Icons.swift index 2a1fc48a5..94e9e8b8a 100644 --- a/Mlem/App/Configuration/Icons.swift +++ b/Mlem/App/Configuration/Icons.swift @@ -29,6 +29,7 @@ enum Icons { static let replyFill: String = "arrowshape.turn.up.left.fill" static let send: String = "paperplane" static let sendFill: String = "paperplane.fill" + static let sendMessage: String = "arrow.up.circle.fill" // save static let save: String = "bookmark" diff --git a/Mlem/App/Globals/Definitions/ErrorsTracker.swift b/Mlem/App/Globals/Definitions/ErrorsTracker.swift index 8543539a6..4f67ff25f 100644 --- a/Mlem/App/Globals/Definitions/ErrorsTracker.swift +++ b/Mlem/App/Globals/Definitions/ErrorsTracker.swift @@ -19,9 +19,9 @@ class ErrorsTracker { static var main: ErrorsTracker = .init() func createErrorLog() -> String { - var ret: String = "" + var ret = "" - errors.forEach { details in + for details in errors { let description = details.error?.localizedDescription ?? "No Description" ret += "\(details.when.formatted(.iso8601))\t\(details.title ?? "Error")\t\(description)\n" } diff --git a/Mlem/App/Utility/Extensions/Content Models/Message1Providing+Extensions.swift b/Mlem/App/Utility/Extensions/Content Models/Message1Providing+Extensions.swift index d6965384c..028ad5db6 100644 --- a/Mlem/App/Utility/Extensions/Content Models/Message1Providing+Extensions.swift +++ b/Mlem/App/Utility/Extensions/Content Models/Message1Providing+Extensions.swift @@ -25,12 +25,14 @@ extension Message1Providing { func allMenuActions( feedback: Set = [.haptic, .toast], isInMessageFeed: Bool = false, + editCallback: (@MainActor () -> Void)?, navigation: NavigationLayer? = nil, report: Report? = nil ) -> [any Action] { basicMenuActions( feedback: feedback, isInMessageFeed: isInMessageFeed, + editCallback: editCallback, navigation: navigation ) if api.isAdmin { @@ -47,6 +49,7 @@ extension Message1Providing { func basicMenuActions( feedback: Set = [.haptic, .toast], isInMessageFeed: Bool = false, + editCallback: (@MainActor () -> Void)?, navigation: NavigationLayer? = nil, report: Report? = nil ) -> [any Action] { @@ -60,6 +63,9 @@ extension Message1Providing { selectTextAction() } if isOwnMessage { + if let editCallback { + editAction(callback: editCallback) + } deleteAction(feedback: feedback) } else { if report == nil { @@ -83,6 +89,14 @@ extension Message1Providing { } } + func editAction(callback: @escaping @MainActor () -> Void) -> BasicAction { + .init( + id: "edit\(uid)", + appearance: .edit(), + callback: api.canInteract ? callback : nil + ) + } + // These actions are also defined in Interactable1Providing... another protocol for these may be a good idea func replyAction(navigation: NavigationLayer) -> BasicAction { diff --git a/Mlem/App/Views/Pages/MessageFeedView/MessageBubbleView.swift b/Mlem/App/Views/Pages/MessageFeedView/MessageBubbleView.swift index 162f81115..c0a7293b6 100644 --- a/Mlem/App/Views/Pages/MessageFeedView/MessageBubbleView.swift +++ b/Mlem/App/Views/Pages/MessageFeedView/MessageBubbleView.swift @@ -14,6 +14,7 @@ struct MessageBubbleView: View { @Environment(Palette.self) var palette let message: any Message + var editCallback: @MainActor () -> Void var body: some View { Group { @@ -33,7 +34,7 @@ struct MessageBubbleView: View { ) .contentShape(.contextMenuPreview, BubbleShape(myMessage: message.isOwnMessage)) .contextMenu { - message.allMenuActions(isInMessageFeed: true, navigation: navigation) + message.allMenuActions(isInMessageFeed: true, editCallback: editCallback, navigation: navigation) } } } diff --git a/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView+Logic.swift b/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView+Logic.swift index b1c42575c..05cd75f1c 100644 --- a/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView+Logic.swift +++ b/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView+Logic.swift @@ -23,4 +23,41 @@ extension MessageFeedView { handleError(error) } } + + func editMessage(_ message: any Message) async { + do { + try await message.edit(content: textView.text) + editing = nil + textView.text = "" + textView.resignFirstResponder() + } catch { + handleError(error) + } + } + + func messageIsFirstOfDay(_ message: Message2) -> Bool { + guard let feedLoader else { return false } + guard let index = feedLoader.items.firstIndex(of: message) else { + assertionFailure() + return false + } + guard index < feedLoader.items.count - 1 else { return true } + let previousMessage = feedLoader.items[index + 1] + return !Calendar.current.isDate(previousMessage.created, inSameDayAs: message.created) + } + + var minTextEditorHeight: CGFloat { + Constants.main.standardSpacing * 2 + UIFont.preferredFont(forTextStyle: .body).lineHeight + } + + func messageFooterText(for message: Message2) -> String? { + var parts: [String] = .init() + if message == feedLoader?.items.first, Calendar.current.isDateInToday(message.created) { + parts.append(message.created.formatted(date: .omitted, time: .shortened)) + } + if message.updated != nil { + parts.append(.init(localized: "Edited")) + } + return parts.joined(separator: " • ") + } } diff --git a/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView.swift b/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView.swift index 69a2e6df4..fece19346 100644 --- a/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView.swift +++ b/Mlem/App/Views/Pages/MessageFeedView/MessageFeedView.swift @@ -16,12 +16,19 @@ struct MessageFeedView: View { let person: AnyPerson let focusTextField: Bool + @State var editing: (any Message)? let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() - init(person: AnyPerson, focusTextField: Bool) { + init(person: AnyPerson, focusTextField: Bool, editing: (any Message)?) { self.person = person self.focusTextField = focusTextField + self._editing = .init(wrappedValue: editing) + if let editing { + let textView = UITextView() + textView.text = editing.content + _textView = .init(wrappedValue: textView) + } } @State var feedLoader: MessageFeedLoader? @@ -41,20 +48,7 @@ struct MessageFeedView: View { CloseButtonView() } } else { - ToolbarItem(placement: .principal) { - NavigationLink(.person(person)) { - HStack(spacing: Constants.main.halfSpacing) { - CircleCroppedImageView(person, frame: 24) - Text(person.displayName) - .foregroundStyle(palette.primary) - .font(.headline) - Image(systemName: Icons.forward) - .imageScale(.small) - .fontWeight(.semibold) - .foregroundStyle(palette.tertiary) - } - } - } + ToolbarItem(placement: .principal) { navigationTitleView(person: person) } ToolbarItemGroup(placement: .secondaryAction) { SwiftUI.Section { if person is any Person3Providing, proxy.isLoading { @@ -77,8 +71,7 @@ struct MessageFeedView: View { } } - @ViewBuilder - func content(person: any Person) -> some View { + @ViewBuilder func content(person: any Person) -> some View { ScrollViewReader { scrollProxy in ScrollView { if let feedLoader { @@ -108,9 +101,7 @@ struct MessageFeedView: View { } } } - .safeAreaInset(edge: .bottom) { - textInput(scrollProxy) - } + .safeAreaInset(edge: .bottom) { textInput(scrollProxy) } .defaultScrollAnchor(.bottom) .scrollDismissesKeyboard(.interactively) .background(palette.groupedBackground) @@ -138,18 +129,22 @@ struct MessageFeedView: View { .padding(.bottom, Constants.main.halfSpacing) } VStack(alignment: message.isOwnMessage ? .trailing : .leading, spacing: Constants.main.halfSpacing) { - MessageBubbleView(message: message) - .padding(message.isOwnMessage ? .leading : .trailing, 50) - .frame(maxWidth: 400, alignment: message.isOwnMessage ? .trailing : .leading) - .onAppear { - do { - try feedLoader.loadIfThreshold(message) - } catch { - handleError(error) - } + MessageBubbleView(message: message, editCallback: { + editing = message + textView.text = message.content + textView.becomeFirstResponder() + }) + .padding(message.isOwnMessage ? .leading : .trailing, 50) + .frame(maxWidth: 400, alignment: message.isOwnMessage ? .trailing : .leading) + .onAppear { + do { + try feedLoader.loadIfThreshold(message) + } catch { + handleError(error) } - if message === feedLoader.items.first, Calendar.current.isDateInToday(message.created) { - Text(message.created.formatted(date: .omitted, time: .shortened)) + } + if let footerText = messageFooterText(for: message) { + Text(footerText) .font(.footnote) .foregroundStyle(palette.secondary) .padding(.horizontal, Constants.main.halfSpacing) @@ -158,10 +153,6 @@ struct MessageFeedView: View { .padding([.horizontal, .bottom], Constants.main.standardSpacing) } - var minTextEditorHeight: CGFloat { - Constants.main.standardSpacing * 2 + UIFont.preferredFont(forTextStyle: .body).lineHeight - } - @ViewBuilder func textInput(_ scrollProxy: ScrollViewProxy) -> some View { OptimalHeightLayout { @@ -169,19 +160,15 @@ struct MessageFeedView: View { ScrollView { textInputView(scrollProxy) } .scrollBounceBehavior(.basedOnSize, axes: .vertical) .scrollIndicators(.hidden) - Button { - Task { @MainActor in - await sendMessage(scrollProxy) + HStack(spacing: 6) { + if editing != nil { + cancelEditButton() } - } label: { - Image(systemName: "arrow.up.circle.fill") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(height: minTextEditorHeight - 12) - .fontWeight(.semibold) - .foregroundStyle(palette.selectedInteractionBarItem, palette.accent) + sendButton(scrollProxy) } + .frame(height: minTextEditorHeight - 12) .padding(6) + .fontWeight(.semibold) } .frame(minHeight: minTextEditorHeight, maxHeight: 200) } @@ -193,6 +180,43 @@ struct MessageFeedView: View { .background(.bar) } + @ViewBuilder + func cancelEditButton() -> some View { + Button { + editing = nil + textView.text = "" + textView.resignFirstResponder() + } label: { + textInputButtonLabel(systemImage: Icons.closeCircleFill) + } + .tint(palette.tertiary) + } + + @ViewBuilder + func sendButton(_ scrollProxy: ScrollViewProxy) -> some View { + Button { + Task { @MainActor in + if let editing { + await editMessage(editing) + } else { + await sendMessage(scrollProxy) + } + } + } label: { + textInputButtonLabel(systemImage: editing == nil ? Icons.sendMessage : Icons.successCircleFill) + } + .tint(palette.accent) + } + + @ViewBuilder + func textInputButtonLabel(systemImage: String) -> some View { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: .infinity) + .foregroundStyle(palette.selectedInteractionBarItem, .tint) + } + @ViewBuilder func textInputView(_ scrollProxy: ScrollViewProxy) -> some View { MarkdownTextEditor( @@ -227,14 +251,19 @@ struct MessageFeedView: View { ) } - func messageIsFirstOfDay(_ message: Message2) -> Bool { - guard let feedLoader else { return false } - guard let index = feedLoader.items.firstIndex(of: message) else { - assertionFailure() - return false + @ViewBuilder + func navigationTitleView(person: any Person) -> some View { + NavigationLink(.person(person)) { + HStack(spacing: Constants.main.halfSpacing) { + CircleCroppedImageView(person, frame: 24) + Text(person.displayName) + .foregroundStyle(palette.primary) + .font(.headline) + Image(systemName: Icons.forward) + .imageScale(.small) + .fontWeight(.semibold) + .foregroundStyle(palette.tertiary) + } } - guard index < feedLoader.items.count - 1 else { return true } - let previousMessage = feedLoader.items[index + 1] - return !Calendar.current.isDate(previousMessage.created, inSameDayAs: message.created) } } diff --git a/Mlem/App/Views/Root/Tabs/Settings/DeveloperSettingsView.swift b/Mlem/App/Views/Root/Tabs/Settings/DeveloperSettingsView.swift index a44133eab..b368132b5 100644 --- a/Mlem/App/Views/Root/Tabs/Settings/DeveloperSettingsView.swift +++ b/Mlem/App/Views/Root/Tabs/Settings/DeveloperSettingsView.swift @@ -5,8 +5,8 @@ // Created by Sjmarf on 22/09/2024. // -import SwiftUI import MlemMiddleware +import SwiftUI // Strings in this view are intentionally left unlocalized; we shouldn't // be burdening translators with these when they'll never be used @@ -23,17 +23,17 @@ struct DeveloperSettingsView: View { } #if DEBUG - Section { - Button(String("Reset Feed Welcome Prompt")) { - showFeedWelcomePrompt = true - } + Section { + Button(String("Reset Feed Welcome Prompt")) { + showFeedWelcomePrompt = true + } - Button(String("Create Error")) { - handleError(ApiClientError.insufficientPermissions) + Button(String("Create Error")) { + handleError(ApiClientError.insufficientPermissions) + } + } header: { + Text(verbatim: "Debug Tools") } - } header: { - Text(verbatim: "Debug Tools") - } #endif } .navigationTitle("Developer") diff --git a/Mlem/App/Views/Shared/MarkdownTextEditor.swift b/Mlem/App/Views/Shared/MarkdownTextEditor.swift index 2dc43dfb3..5638be72c 100644 --- a/Mlem/App/Views/Shared/MarkdownTextEditor.swift +++ b/Mlem/App/Views/Shared/MarkdownTextEditor.swift @@ -151,6 +151,7 @@ struct MarkdownTextEditor: UIViewRepresentable { } func textViewDidBeginEditing(_ textView: UITextView) { + parent.placeholderLabel.isHidden = !textView.text.isEmpty parent.onBeginEditing() } } diff --git a/Mlem/App/Views/Shared/MessageView.swift b/Mlem/App/Views/Shared/MessageView.swift index 29254c2ba..6130ae522 100644 --- a/Mlem/App/Views/Shared/MessageView.swift +++ b/Mlem/App/Views/Shared/MessageView.swift @@ -71,12 +71,12 @@ struct MessageView: View { .clipShape(.rect(cornerRadius: Constants.main.standardSpacing)) .contentShape(.contextMenuPreview, .rect(cornerRadius: Constants.main.standardSpacing)) .contextMenu { - message.allMenuActions(navigation: navigation, report: reportContext) + message.allMenuActions(editCallback: editMessage, navigation: navigation, report: reportContext) } .paletteBorder(cornerRadius: Constants.main.standardSpacing) .onTapGesture { - if let creator = (message.isOwnMessage ? message.recipient_ : message.creator_), message.api.canInteract { - navigation.push(.messageFeed(creator)) + if let otherPerson, message.api.canInteract { + navigation.push(.messageFeed(otherPerson)) } } } @@ -90,13 +90,24 @@ struct MessageView: View { } } EllipsisMenu(size: 24) { - message.basicMenuActions(navigation: navigation) + message.basicMenuActions(editCallback: editMessage, navigation: navigation) } } else { EllipsisMenu(size: 24) { - message.allMenuActions(navigation: navigation, report: reportContext) + message.allMenuActions(editCallback: editMessage, navigation: navigation, report: reportContext) } } } } + + var otherPerson: Person1? { + message.isOwnMessage ? message.recipient_ : message.creator_ + } + + @MainActor + func editMessage() { + if let otherPerson { + navigation.push(.messageFeed(otherPerson, focusTextField: true, editing: message)) + } + } } diff --git a/Mlem/App/Views/Shared/Navigation/NavigationPage+View.swift b/Mlem/App/Views/Shared/Navigation/NavigationPage+View.swift index 740dddb5a..fece29dab 100644 --- a/Mlem/App/Views/Shared/Navigation/NavigationPage+View.swift +++ b/Mlem/App/Views/Shared/Navigation/NavigationPage+View.swift @@ -160,8 +160,8 @@ extension NavigationPage { AdvancedSortView(selectedSort: sort.wrappedValue) case let .votesList(target): VotesListView(target: target) - case let .messageFeed(person, focusTextField: focusTextField): - MessageFeedView(person: person, focusTextField: focusTextField) + case let .messageFeed(person, focusTextField: focusTextField, editing: editing): + MessageFeedView(person: person, focusTextField: focusTextField, editing: editing?.wrappedValue) case let .modlog(community: community): ModlogView(community: community) } diff --git a/Mlem/App/Views/Shared/Navigation/NavigationPage.swift b/Mlem/App/Views/Shared/Navigation/NavigationPage.swift index 40eeca021..96b3cadec 100644 --- a/Mlem/App/Views/Shared/Navigation/NavigationPage.swift +++ b/Mlem/App/Views/Shared/Navigation/NavigationPage.swift @@ -27,7 +27,7 @@ enum NavigationPage: Hashable { case person(_ person: AnyPerson) case instance(_ instance: InstanceHashWrapper) case instanceOpinionList(instance: InstanceHashWrapper, opinionType: FediseerOpinionType, data: FediseerData) - case messageFeed(_ person: AnyPerson, focusTextField: Bool) + case messageFeed(_ person: AnyPerson, focusTextField: Bool, editing: MessageHashWrapper?) case fediseerInfo case externalApiInfo(api: ApiClient, actorId: URL) case imageViewer(_ url: URL) @@ -119,8 +119,16 @@ enum NavigationPage: Hashable { ) } - static func messageFeed(_ person: any PersonStubProviding, focusTextField: Bool = false) -> NavigationPage { - messageFeed(.init(person), focusTextField: focusTextField) + static func messageFeed( + _ person: any PersonStubProviding, + focusTextField: Bool = false, + editing: (any Message1Providing)? = nil + ) -> NavigationPage { + var editingWrapper: MessageHashWrapper? + if let editing { + editingWrapper = .init(wrappedValue: editing) + } + return messageFeed(.init(person), focusTextField: focusTextField, editing: editingWrapper) } static func instance(hostOf entity: any ActorIdentifiable) -> NavigationPage { @@ -377,3 +385,15 @@ struct Profile2HashWrapper: Hashable { lhs.hashValue == rhs.hashValue } } + +struct MessageHashWrapper: Hashable { + var wrappedValue: any Message1Providing + + func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue.actorId) + } + + static func == (lhs: MessageHashWrapper, rhs: MessageHashWrapper) -> Bool { + lhs.hashValue == rhs.hashValue + } +} diff --git a/Mlem/Localizable.xcstrings b/Mlem/Localizable.xcstrings index 56c94925b..52a29c5ad 100644 --- a/Mlem/Localizable.xcstrings +++ b/Mlem/Localizable.xcstrings @@ -681,6 +681,9 @@ }, "Edit" : { + }, + "Edited" : { + }, "Email" : {