From 4acba65f6c18ca5f1b8724780d92061b0aaa308e Mon Sep 17 00:00:00 2001 From: commiekong <30882689+dfsm@users.noreply.github.com> Date: Wed, 7 Jan 2026 12:25:50 +1100 Subject: [PATCH 1/6] Initial commit for Sent and custom folder support in private messages --- App/Data Sources/MessageListDataSource.swift | 37 +- App/Resources/Localizable.xcstrings | 233 +++++++++- ...essageFolderManagementViewController.swift | 288 +++++++++++++ .../Messages/MessageFolderPickerView.swift | 304 +++++++++++++ .../Messages/MessageListViewController.swift | 398 +++++++++++++++++- .../Messages/MessageViewController.swift | 5 +- Awful.xcodeproj/project.pbxproj | 10 +- .../Awful.xcdatamodeld/.xccurrentversion | 2 +- .../Awful 7.11.xcdatamodel/contents | 198 +++++++++ .../AwfulCore/Model/PrivateMessage.swift | 19 +- .../Model/PrivateMessageFolder.swift | 47 +++ AwfulCore/Sources/AwfulCore/Model/User.swift | 21 + .../AwfulCore/Networking/ForumsClient.swift | 271 +++++++++++- .../PrivateMessagePersistence.swift | 76 +++- 14 files changed, 1867 insertions(+), 42 deletions(-) create mode 100644 App/View Controllers/Messages/MessageFolderManagementViewController.swift create mode 100644 App/View Controllers/Messages/MessageFolderPickerView.swift create mode 100644 AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/Awful 7.11.xcdatamodel/contents create mode 100644 AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift diff --git a/App/Data Sources/MessageListDataSource.swift b/App/Data Sources/MessageListDataSource.swift index 8deb5408b..6603d8a3a 100644 --- a/App/Data Sources/MessageListDataSource.swift +++ b/App/Data Sources/MessageListDataSource.swift @@ -14,11 +14,18 @@ final class MessageListDataSource: NSObject { weak var deletionDelegate: MessageListDataSourceDeletionDelegate? private let resultsController: NSFetchedResultsController private let tableView: UITableView + private let folder: PrivateMessageFolder? - init(managedObjectContext: NSManagedObjectContext, tableView: UITableView) throws { + init(managedObjectContext: NSManagedObjectContext, tableView: UITableView, folder: PrivateMessageFolder?) throws { let fetchRequest = PrivateMessage.makeFetchRequest() fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(PrivateMessage.sentDate), ascending: false)] + + if let folder = folder { + fetchRequest.predicate = NSPredicate(format: "folder == %@", folder) + } + resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: managedObjectContext, sectionNameKeyPath: nil, cacheName: nil) + self.folder = folder self.tableView = tableView super.init() @@ -34,6 +41,10 @@ final class MessageListDataSource: NSObject { func message(at indexPath: IndexPath) -> PrivateMessage { return resultsController.object(at: indexPath) } + + func indexPath(for message: PrivateMessage) -> IndexPath? { + return resultsController.indexPath(forObject: message) + } } private let cellReuseIdentifier = "MessageCell" @@ -98,11 +109,16 @@ extension MessageListDataSource: UITableViewDataSource { private func viewModelForMessage(at indexPath: IndexPath) -> MessageListCell.ViewModel { let message = self.message(at: indexPath) let theme = Theme.defaultTheme() - + + let displayName = message.isSent + ? (message.to?.username ?? "Unknown") + : (message.fromUsername ?? "") + let labelPrefix = message.isSent ? "To: " : "" + return MessageListCell.ViewModel( backgroundColor: theme["listBackgroundColor"]!, selectedBackgroundColor: theme["listSelectedBackgroundColor"]!, - sender: NSAttributedString(string: message.fromUsername ?? "", attributes: [ + sender: NSAttributedString(string: labelPrefix + displayName, attributes: [ .font: UIFont.preferredFontForTextStyle(.body, fontName: nil, sizeAdjustment: theme[double: "messageListSenderFontSizeAdjustment"]!, weight: .semibold), .foregroundColor: theme[uicolor: "listSecondaryTextColor"]!]), sentDate: message.sentDate ?? .distantPast, @@ -117,7 +133,16 @@ extension MessageListDataSource: UITableViewDataSource { .foregroundColor: theme[uicolor: "listTextColor"]!]), tagImage: .image(name: message.threadTag?.imageName, placeholder: .privateMessage), tagOverlayImage: { - if message.replied { + if message.isSent { + let image = UIImage(named: "pmforwarded")? + .stroked(with: theme["listBackgroundColor"]!, thickness: 3, quality: 1) + .withRenderingMode(.alwaysTemplate) + + let imageView = UIImageView(image: image) + imageView.tintColor = theme["tintColor"]! + + return imageView + } else if message.replied { let image = UIImage(named: "pmreplied")? .stroked(with: theme["listBackgroundColor"]!, thickness: 3, quality: 1) .withRenderingMode(.alwaysTemplate) @@ -126,7 +151,7 @@ extension MessageListDataSource: UITableViewDataSource { imageView.tintColor = theme["listBackgroundColor"]! return imageView - } else if message.forwarded { + } else if message.forwarded && !message.isSent { let image = UIImage(named: "pmforwarded")? .stroked(with: theme["listBackgroundColor"]!, thickness: 3, quality: 1) .withRenderingMode(.alwaysTemplate) @@ -135,7 +160,7 @@ extension MessageListDataSource: UITableViewDataSource { imageView.tintColor = theme["listBackgroundColor"]! return imageView - } else if !message.seen { + } else if !message.seen && !message.isSent { let image = UIImage(named: "newpm") let imageView = UIImageView(image: image) diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 0324b5296..bc77cfa52 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -848,6 +848,138 @@ } } }, + "private-message-folder.add-message" : { + "comment" : "Message in new folder dialog", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a name for the new folder" + } + } + } + }, + "private-message-folder.add-title" : { + "comment" : "Title of new folder dialog", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Folder" + } + } + } + }, + "private-message-folder.create" : { + "comment" : "Button to create new folder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create" + } + } + } + }, + "private-message-folder.create-error-title" : { + "comment" : "Error title when folder creation fails", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not create folder" + } + } + } + }, + "private-message-folder.custom-folders-header" : { + "comment" : "Header for custom folders section", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Folders" + } + } + } + }, + "private-message-folder.delete-error-title" : { + "comment" : "Error title when folder deletion fails", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not delete folder" + } + } + } + }, + "private-message-folder.inbox" : { + "comment" : "Label for inbox folder in folder picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inbox" + } + } + } + }, + "private-message-folder.manage" : { + "comment" : "Menu item to manage folders", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Folders" + } + } + } + }, + "private-message-folder.manage-title" : { + "comment" : "Title of folder management screen", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Folders" + } + } + } + }, + "private-message-folder.more" : { + "comment" : "Label for more folders dropdown button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folders" + } + } + } + }, + "private-message-folder.name-placeholder" : { + "comment" : "Placeholder text for folder name field", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folder name" + } + } + } + }, + "private-message-folder.sent" : { + "comment" : "Label for sent folder in folder picker", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sent" + } + } + } + }, "private-message-list.compose-button.accessibility-label" : { "comment" : "Text read by VoiceOver for compose button in private messages list.", "extractionState" : "migrated", @@ -932,6 +1064,28 @@ } } }, + "private-messages-list.delete-confirm.message" : { + "comment" : "Message of confirmation alert when deleting multiple messages. Parameter is the count.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Are you sure you want to delete %d messages?" + } + } + } + }, + "private-messages-list.delete-confirm.title" : { + "comment" : "Title of confirmation alert when deleting multiple messages.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Messages?" + } + } + } + }, "private-messages-list.deletion-error.title" : { "comment" : "Title of alert shown when there's a problem deleting a private message from the list.", "extractionState" : "migrated", @@ -956,6 +1110,72 @@ } } }, + "private-messages-list.move-error.title" : { + "comment" : "Title of alert shown when there's a problem moving a private message to a different folder.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could Not Move Message" + } + } + } + }, + "private-messages-list.move-folder.message" : { + "comment" : "Message of alert shown when selecting a folder to move a message to.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a folder to move this message to:" + } + } + } + }, + "private-messages-list.move-folder.title" : { + "comment" : "Title of alert shown when selecting a folder to move a message to.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to Folder" + } + } + } + }, + "private-messages-list.move-multiple.message" : { + "comment" : "Message shown when moving multiple messages. Parameter is the count of messages.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move %d messages to:" + } + } + } + }, + "private-messages-list.no-selection.message" : { + "comment" : "Message shown when trying to perform an action without selecting any messages.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please select at least one message first." + } + } + } + }, + "private-messages-list.no-selection.title" : { + "comment" : "Title shown when trying to perform an action without selecting any messages.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Messages Selected" + } + } + } + }, "Problem Logging In" : { }, @@ -1192,6 +1412,17 @@ } } }, + "table-view.action.move" : { + "comment" : "Text of move button shown in tables after swiping on the cell in edit mode.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move" + } + } + } + }, "thread-list.filter-button.change-filter" : { "comment" : "Button title atop the thread list when some filter is set.", "extractionState" : "migrated", @@ -1263,4 +1494,4 @@ } }, "version" : "1.0" -} \ No newline at end of file +} diff --git a/App/View Controllers/Messages/MessageFolderManagementViewController.swift b/App/View Controllers/Messages/MessageFolderManagementViewController.swift new file mode 100644 index 000000000..ca708c9fd --- /dev/null +++ b/App/View Controllers/Messages/MessageFolderManagementViewController.swift @@ -0,0 +1,288 @@ +// MessageFolderManagementViewController.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import UIKit +import AwfulCore +import AwfulTheming +import CoreData +import os + +private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MessageFolderManagement") + +final class MessageFolderManagementViewController: TableViewController { + + private let managedObjectContext: NSManagedObjectContext + private var folders: [PrivateMessageFolder] = [] + var onFoldersChanged: (() -> Void)? + + init(managedObjectContext: NSManagedObjectContext) { + self.managedObjectContext = managedObjectContext + super.init(style: .grouped) + + title = LocalizedString("private-message-folder.manage-title") + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "FolderCell") + + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(doneButtonTapped) + ) + + // Create both Edit and Add buttons + let editButton = editButtonItem + let addButton = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addButtonTapped) + ) + navigationItem.rightBarButtonItems = [addButton, editButton] + + loadFolders() + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + tableView.setEditing(editing, animated: animated) + + // Reload footer to update text + if tableView.footerView(forSection: 0) != nil { + tableView.reloadSections(IndexSet(integer: 0), with: .none) + } + } + + private func loadFolders() { + logger.info("[FOLDER_MGT] Loading folders list") + Task { + do { + let allFolders = try await ForumsClient.shared.listPrivateMessageFolders() + logger.info("[FOLDER_MGT] Loaded \(allFolders.count) total folders") + await MainActor.run { + self.folders = allFolders.filter { $0.isCustom } + logger.info("[FOLDER_MGT] Filtered to \(self.folders.count) custom folders") + for folder in self.folders { + logger.debug("[FOLDER_MGT] - Folder: '\(folder.name)' (ID: \(folder.folderID))") + } + self.tableView.reloadData() + } + } catch { + logger.error("[FOLDER_MGT] Failed to load folders: \(error)") + await MainActor.run { + let alert = UIAlertController(networkError: error) + present(alert, animated: true) + } + } + } + } + + @objc private func doneButtonTapped() { + dismiss(animated: true) + } + + @objc private func addButtonTapped() { + let alert = UIAlertController( + title: LocalizedString("private-message-folder.add-title"), + message: LocalizedString("private-message-folder.add-message"), + preferredStyle: .alert + ) + + alert.addTextField { [weak self] textField in + textField.placeholder = LocalizedString("private-message-folder.name-placeholder") + textField.autocapitalizationType = .none + textField.addTarget(self, action: #selector(self?.textFieldDidChange(_:)), for: .editingChanged) + } + + let createAction = UIAlertAction( + title: LocalizedString("private-message-folder.create"), + style: .default + ) { [weak self] _ in + guard let folderName = alert.textFields?.first?.text, + !folderName.isEmpty, + folderName.count <= 25 else { return } + self?.createFolder(name: folderName) + } + // Start with create button disabled + createAction.isEnabled = false + + let cancelAction = UIAlertAction( + title: LocalizedString("cancel"), + style: .cancel + ) + + alert.addAction(createAction) + alert.addAction(cancelAction) + + present(alert, animated: true) + } + + @objc private func textFieldDidChange(_ textField: UITextField) { + // Limit to 25 characters + if let text = textField.text, text.count > 25 { + textField.text = String(text.prefix(25)) + } + + // Enable/disable create button based on text length + if let alertController = presentedViewController as? UIAlertController, + let text = textField.text, + !text.isEmpty, + text.count <= 25 { + alertController.actions.first?.isEnabled = true + } else if let alertController = presentedViewController as? UIAlertController { + alertController.actions.first?.isEnabled = false + } + } + + private func createFolder(name: String) { + logger.info("[FOLDER_MGT] Creating folder with name: '\(name)'") + Task { + do { + logger.info("[FOLDER_MGT] Calling API to create folder: '\(name)'") + try await ForumsClient.shared.createPrivateMessageFolder(name: name) + logger.info("[FOLDER_MGT] Successfully created folder: '\(name)'") + loadFolders() + await MainActor.run { [weak self] in + self?.onFoldersChanged?() + } + } catch { + logger.error("[FOLDER_MGT] Failed to create folder '\(name)': \(error)") + await MainActor.run { + let alert = UIAlertController( + title: LocalizedString("private-message-folder.create-error-title"), + error: error + ) + present(alert, animated: true) + } + } + } + } + + private func deleteFolder(at indexPath: IndexPath) { + let folder = folders[indexPath.row] + logger.info("[FOLDER_MGT] Deleting folder: '\(folder.name)' with ID: '\(folder.folderID)'") + + Task { + do { + // First, move all messages in this folder to inbox or sent + logger.info("[FOLDER_MGT] Moving messages from folder '\(folder.name)' before deletion") + + // Fetch all messages in this folder + let messages = try await ForumsClient.shared.listPrivateMessagesInFolder(folderID: folder.folderID) + logger.info("[FOLDER_MGT] Found \(messages.count) messages to move") + + // Get current username to determine sent messages + let currentUsername = UserDefaults.standard.string(forKey: "com.awfulapp.Awful.username") + + // Move each message to the appropriate folder + for message in messages { + // Check if message was sent by the current user + let wasSentByCurrentUser = message.from?.username == currentUsername + let targetFolderID = wasSentByCurrentUser ? "-1" : "0" // -1 for sent, 0 for inbox + logger.info("[FOLDER_MGT] Moving message '\(message.subject ?? "")' from '\(message.from?.username ?? "unknown")' to \(wasSentByCurrentUser ? "sent" : "inbox")") + try await ForumsClient.shared.movePrivateMessage(message, toFolderID: targetFolderID) + } + + logger.info("[FOLDER_MGT] All messages moved. Now deleting folder ID: '\(folder.folderID)'") + try await ForumsClient.shared.deletePrivateMessageFolder(folderID: folder.folderID) + logger.info("[FOLDER_MGT] Successfully deleted folder: '\(folder.name)'") + await MainActor.run { [weak self] in + self?.folders.remove(at: indexPath.row) + self?.tableView.deleteRows(at: [indexPath], with: .automatic) + self?.onFoldersChanged?() + } + } catch { + logger.error("[FOLDER_MGT] Failed to delete folder '\(folder.name)' (ID: \(folder.folderID)): \(error)") + await MainActor.run { + let alert = UIAlertController( + title: LocalizedString("private-message-folder.delete-error-title"), + error: error + ) + present(alert, animated: true) + } + } + } + } + + override func themeDidChange() { + super.themeDidChange() + + tableView.separatorColor = theme["listSeparatorColor"] + tableView.backgroundColor = theme["backgroundColor"] + } +} + +// MARK: UITableViewDataSource +extension MessageFolderManagementViewController { + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return folders.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "FolderCell", for: indexPath) + let folder = folders[indexPath.row] + + cell.textLabel?.text = folder.name + cell.textLabel?.textColor = theme[uicolor: "listTextColor"] + cell.backgroundColor = theme["listBackgroundColor"] + + let selectedView = UIView() + selectedView.backgroundColor = theme["listSelectedBackgroundColor"] + cell.selectedBackgroundView = selectedView + + return cell + } +} + +// MARK: UITableViewDelegate +extension MessageFolderManagementViewController { + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return LocalizedString("private-message-folder.custom-folders-header") + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + if tableView.isEditing { + return "Tap the red circle to delete folders. Messages will be moved to Inbox or Sent based on their origin." + } + return "Tap Edit to delete folders." + } + + override func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { + if let header = view as? UITableViewHeaderFooterView { + header.textLabel?.textColor = theme[uicolor: "listSecondaryTextColor"] + } + } + + override func tableView(_ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int) { + if let footer = view as? UITableViewHeaderFooterView { + footer.textLabel?.textColor = theme[uicolor: "listSecondaryTextColor"] + footer.textLabel?.font = UIFont.preferredFont(forTextStyle: .footnote) + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + // Only allow editing when in edit mode + return tableView.isEditing + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + deleteFolder(at: indexPath) + } + } + + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + // Disable swipe-to-delete - only allow deletion in edit mode + return nil + } +} diff --git a/App/View Controllers/Messages/MessageFolderPickerView.swift b/App/View Controllers/Messages/MessageFolderPickerView.swift new file mode 100644 index 000000000..4b72e202f --- /dev/null +++ b/App/View Controllers/Messages/MessageFolderPickerView.swift @@ -0,0 +1,304 @@ +// MessageFolderPickerView.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import UIKit +import AwfulCore +import AwfulTheming + +protocol MessageFolderPickerViewDelegate: AnyObject { + func folderPicker(_ picker: MessageFolderPickerView, didSelectFolder folder: PrivateMessageFolder) + func folderPickerDidRequestManageFolders(_ picker: MessageFolderPickerView) +} + +final class MessageFolderPickerView: UIView { + + weak var delegate: MessageFolderPickerViewDelegate? + + private let segmentedControl: UISegmentedControl + private var customFolders: [PrivateMessageFolder] = [] + private var currentFolder: PrivateMessageFolder? + private var showingFoldersMenu = false + + override init(frame: CGRect) { + self.segmentedControl = UISegmentedControl(items: [ + LocalizedString("private-message-folder.inbox"), + LocalizedString("private-message-folder.sent"), + LocalizedString("private-message-folder.more") + ]) + + super.init(frame: frame) + + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViews() { + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + segmentedControl.selectedSegmentIndex = 0 + segmentedControl.addTarget(self, action: #selector(segmentChanged), for: .valueChanged) + + // Add touch handler for detecting taps on already-selected segment + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSegmentTap(_:))) + tapGesture.delegate = self + segmentedControl.addGestureRecognizer(tapGesture) + + // Set proportional segment widths to fit properly + segmentedControl.apportionsSegmentWidthsByContent = true + + // Make container transparent + self.backgroundColor = .clear + self.isOpaque = false + + addSubview(segmentedControl) + + NSLayoutConstraint.activate([ + segmentedControl.leadingAnchor.constraint(equalTo: leadingAnchor), + segmentedControl.trailingAnchor.constraint(equalTo: trailingAnchor), + segmentedControl.topAnchor.constraint(equalTo: topAnchor), + segmentedControl.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @objc private func segmentChanged() { + switch segmentedControl.selectedSegmentIndex { + case 0: + if let folder = customFolders.first(where: { $0.folderID == "0" }) { + currentFolder = folder + delegate?.folderPicker(self, didSelectFolder: folder) + } + case 1: + if let folder = customFolders.first(where: { $0.folderID == "-1" }) { + currentFolder = folder + delegate?.folderPicker(self, didSelectFolder: folder) + } + case 2: + // Check if a custom folder is already set (segment shows folder name, not "Folders") + let segmentTitle = segmentedControl.titleForSegment(at: 2) ?? "" + let defaultTitle = LocalizedString("private-message-folder.more") + + if segmentTitle == defaultTitle { + // No folder selected, show menu + showFoldersMenu() + } else { + // A custom folder name is shown - find and load it + if let customFolder = customFolders.first(where: { $0.name == segmentTitle && $0.isCustom }) { + currentFolder = customFolder + delegate?.folderPicker(self, didSelectFolder: customFolder) + } else { + // Can't find the folder, show menu as fallback + showFoldersMenu() + } + } + default: + break + } + } + + private func showFoldersMenu() { + // Find the view controller to present from + guard let viewController = self.findViewController() else { + restorePreviousSelection() + return + } + + // Create an alert controller with the menu items + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + // Apply theme appearance + let menuAppearance = Theme.defaultTheme()[string: "menuAppearance"] + alertController.overrideUserInterfaceStyle = menuAppearance == "light" ? .light : .dark + + // Get custom folders + let customFoldersList = customFolders.filter({ $0.isCustom }) + + // If there are no custom folders, only show the manage option + if !customFoldersList.isEmpty { + // Add custom folder actions + for folder in customFoldersList { + let isSelected = currentFolder?.folderID == folder.folderID + let title = isSelected ? "✓ \(folder.name)" : folder.name + alertController.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in + self?.selectCustomFolder(folder) + }) + } + } + + // Add manage folders option + alertController.addAction(UIAlertAction( + title: LocalizedString("private-message-folder.manage"), + style: .default + ) { [weak self] _ in + guard let self = self else { return } + self.restorePreviousSelection() + self.delegate?.folderPickerDidRequestManageFolders(self) + }) + + // Add cancel action - restore selection when cancelled + alertController.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel) { [weak self] _ in + self?.restorePreviousSelection() + }) + + // Configure for iPad - position from the Folders segment + if let popover = alertController.popoverPresentationController { + popover.sourceView = segmentedControl + // Calculate the rect for the third segment (index 2) + let segmentWidth = segmentedControl.bounds.width / CGFloat(segmentedControl.numberOfSegments) + let thirdSegmentRect = CGRect(x: segmentWidth * 2, y: 0, width: segmentWidth, height: segmentedControl.bounds.height) + popover.sourceRect = thirdSegmentRect + popover.permittedArrowDirections = [.up] + + // Add delegate to handle dismissal + popover.delegate = self + } + + // Present without restoring in completion - let user action or dismissal handle it + viewController.present(alertController, animated: true) + } + + private func restorePreviousSelection() { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if let current = self.currentFolder { + switch current.folderID { + case "0": + self.segmentedControl.selectedSegmentIndex = 0 + case "-1": + self.segmentedControl.selectedSegmentIndex = 1 + default: + self.segmentedControl.selectedSegmentIndex = UISegmentedControl.noSegment + } + } + } + } + + func updateFolders(_ folders: [PrivateMessageFolder]) { + self.customFolders = folders + + // Check current folder still exists, otherwise switch to inbox + if let current = currentFolder { + // Check if the current folder still exists + if folders.contains(where: { $0.folderID == current.folderID }) { + selectFolder(current) + } else { + // Current folder was deleted, switch to inbox and reset third segment title + if segmentedControl.selectedSegmentIndex == 2 { + segmentedControl.setTitle(LocalizedString("private-message-folder.more"), forSegmentAt: 2) + } + if let inbox = folders.first(where: { $0.folderID == "0" }) { + selectFolder(inbox) + delegate?.folderPicker(self, didSelectFolder: inbox) + } + } + } else if let current = currentFolder { + selectFolder(current) + } + + // If there are no custom folders and the third segment shows a custom folder name, + // reset it to "Folders" + let hasCustomFolders = folders.contains { $0.isCustom } + if !hasCustomFolders && segmentedControl.numberOfSegments == 3 { + // Only reset if it's not already showing "Folders" + if let currentTitle = segmentedControl.titleForSegment(at: 2), + currentTitle != LocalizedString("private-message-folder.more") { + segmentedControl.setTitle(LocalizedString("private-message-folder.more"), forSegmentAt: 2) + } + } + } + + func selectFolder(_ folder: PrivateMessageFolder) { + currentFolder = folder + + switch folder.folderID { + case "0": + segmentedControl.selectedSegmentIndex = 0 + case "-1": + segmentedControl.selectedSegmentIndex = 1 + default: + if segmentedControl.numberOfSegments == 3 { + let customTitle = folder.name + segmentedControl.setTitle(customTitle, forSegmentAt: 2) + segmentedControl.selectedSegmentIndex = 2 + } + } + } + + private func selectCustomFolder(_ folder: PrivateMessageFolder) { + currentFolder = folder + // Just show the folder name when it's selected + segmentedControl.setTitle(folder.name, forSegmentAt: 2) + segmentedControl.selectedSegmentIndex = 2 + delegate?.folderPicker(self, didSelectFolder: folder) + } + + func applyTheme(_ theme: Theme) { + segmentedControl.backgroundColor = theme[uicolor: "ratingIconEmptyColor"] + segmentedControl.selectedSegmentTintColor = theme[uicolor: "tintColor"] + + let normalTextAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: theme[uicolor: "navigationBarTextColor"] ?? UIColor.label + ] + segmentedControl.setTitleTextAttributes(normalTextAttributes, for: .normal) + + let selectedTextAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: theme[uicolor: "navigationBarTextColor"] ?? UIColor.label + ] + segmentedControl.setTitleTextAttributes(selectedTextAttributes, for: .selected) + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + return viewController + } + responder = nextResponder + } + return nil + } + + @objc private func handleSegmentTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: segmentedControl) + let segmentWidth = segmentedControl.bounds.width / CGFloat(segmentedControl.numberOfSegments) + let tappedSegment = Int(location.x / segmentWidth) + + // If tapping the third segment (Folders) and it's already selected, show menu + if tappedSegment == 2 && segmentedControl.selectedSegmentIndex == 2 { + showFoldersMenu() + } + } +} + +// MARK: - UIGestureRecognizerDelegate +extension MessageFolderPickerView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + // Only handle the gesture if it's on the third segment and it's already selected + let location = touch.location(in: segmentedControl) + let segmentWidth = segmentedControl.bounds.width / CGFloat(segmentedControl.numberOfSegments) + let tappedSegment = Int(location.x / segmentWidth) + + // Only intercept if tapping the already-selected third segment + // (regardless of whether it shows "Folders" or a folder name) + return tappedSegment == 2 && segmentedControl.selectedSegmentIndex == 2 + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } +} + +extension MessageFolderPickerView: UIPopoverPresentationControllerDelegate { + func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + restorePreviousSelection() + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + guard index >= 0, index < count else { return nil } + return self[index] + } +} diff --git a/App/View Controllers/Messages/MessageListViewController.swift b/App/View Controllers/Messages/MessageListViewController.swift index 02ae18f2a..17c95b2a4 100644 --- a/App/View Controllers/Messages/MessageListViewController.swift +++ b/App/View Controllers/Messages/MessageListViewController.swift @@ -20,6 +20,11 @@ final class MessageListViewController: TableViewController { private let managedObjectContext: NSManagedObjectContext @FoilDefaultStorage(Settings.showThreadTags) private var showThreadTags private var unreadMessageCountObserver: ManagedObjectCountObserver! + private var folderPicker: MessageFolderPickerView? + private var folderPickerContainer: UIView? + private var currentFolder: PrivateMessageFolder? + private var allFolders: [PrivateMessageFolder] = [] + private var editToolbar: UIToolbar? init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext @@ -58,7 +63,8 @@ final class MessageListViewController: TableViewController { private func makeDataSource() -> MessageListDataSource { let dataSource = try! MessageListDataSource( managedObjectContext: managedObjectContext, - tableView: tableView) + tableView: tableView, + folder: currentFolder) dataSource.deletionDelegate = self return dataSource } @@ -95,11 +101,17 @@ final class MessageListViewController: TableViewController { @objc private func refresh() { startAnimatingPullToRefresh() - + Task { do { - _ = try await ForumsClient.shared.listPrivateMessagesInInbox() - RefreshMinder.sharedMinder.didRefresh(.privateMessagesInbox) + let folderID = currentFolder?.folderID ?? "0" + _ = try await ForumsClient.shared.listPrivateMessagesInFolder(folderID: folderID) + + if folderID == "0" { + RefreshMinder.sharedMinder.didRefresh(.privateMessagesInbox) + } + + await loadFolders() } catch { if visible { let alert = UIAlertController(networkError: error) @@ -109,6 +121,41 @@ final class MessageListViewController: TableViewController { stopAnimatingPullToRefresh() } } + + private func loadFolders() async { + do { + let folders = try await ForumsClient.shared.listPrivateMessageFolders() + await MainActor.run { + self.allFolders = folders + self.folderPicker?.updateFolders(folders) + + // Check if current folder still exists, otherwise switch to inbox + if let current = currentFolder { + if !folders.contains(where: { $0.folderID == current.folderID }) { + // Current folder was deleted, switch to inbox + if let inbox = folders.first(where: { $0.folderID == "0" }) { + setCurrentFolder(inbox) + } + } + } else if currentFolder == nil, let inbox = folders.first(where: { $0.folderID == "0" }) { + setCurrentFolder(inbox) + } + } + } catch { + logger.error("Failed to load folders: \(error)") + } + } + + private func setCurrentFolder(_ folder: PrivateMessageFolder) { + guard folder != currentFolder else { return } + currentFolder = folder + folderPicker?.selectFolder(folder) + + dataSource = makeDataSource() + tableView.reloadData() + + UserDefaults.standard.set(folder.folderID, forKey: "MessageListLastFolderID") + } func showMessage(_ message: PrivateMessage) { if enableHaptics { @@ -137,6 +184,60 @@ final class MessageListViewController: TableViewController { } } + private func showFolderPicker(for message: PrivateMessage) { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.move-folder.title"), + message: LocalizedString("private-messages-list.move-folder.message"), + preferredStyle: .actionSheet + ) + + // Add folder options, excluding the current folder + for folder in allFolders where folder.folderID != currentFolder?.folderID { + let folderName: String + if folder.folderID == "0" { + folderName = LocalizedString("private-message-folder.inbox") + } else if folder.folderID == "-1" { + folderName = LocalizedString("private-message-folder.sent") + } else { + folderName = folder.name + } + + alert.addAction(UIAlertAction(title: folderName, style: .default) { [weak self] _ in + self?.moveMessage(message, to: folder) + }) + } + + alert.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel)) + + // Configure for iPad + if let popover = alert.popoverPresentationController { + popover.sourceView = tableView + if let indexPath = dataSource?.indexPath(for: message) { + popover.sourceRect = tableView.rectForRow(at: indexPath) + } + } + + present(alert, animated: true) + } + + private func moveMessage(_ message: PrivateMessage, to folder: PrivateMessageFolder) { + Task { + do { + try await ForumsClient.shared.movePrivateMessage(message, toFolderID: folder.folderID) + // The message will automatically disappear from the current folder view + // due to the NSFetchedResultsController detecting the folder change + } catch { + if visible { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.move-error.title"), + error: error + ) + present(alert, animated: true) + } + } + } + } + private func recalculateSeparatorInset() { tableView.separatorInset.left = MessageListCell.separatorLeftInset( showsTagAndRating: showThreadTags, @@ -148,10 +249,15 @@ final class MessageListViewController: TableViewController { override func viewDidLoad() { super.viewDidLoad() - + + // Setup folder picker first, before table view configuration + setupFolderPicker() + tableView.estimatedRowHeight = 65 recalculateSeparatorInset() + loadInitialFolder() + dataSource = makeDataSource() tableView.reloadData() @@ -159,6 +265,69 @@ final class MessageListViewController: TableViewController { self.refresh() } } + + private func setupFolderPicker() { + // Create a container view for the fixed header + let headerView = UIView() + headerView.translatesAutoresizingMaskIntoConstraints = false + headerView.isUserInteractionEnabled = true + + headerView.backgroundColor = .clear + + let picker = MessageFolderPickerView() + picker.delegate = self + + picker.applyTheme(theme) + + picker.translatesAutoresizingMaskIntoConstraints = false + picker.isUserInteractionEnabled = true + folderPicker = picker + + headerView.addSubview(picker) + folderPickerContainer = headerView + + // Add header view to the table view's parent (which is the root view for UITableViewController) + // We need to add it after the table view is loaded + view.addSubview(headerView) + // Bring header to front so it appears above the table view + view.bringSubviewToFront(headerView) + + NSLayoutConstraint.activate([ + // Header view constraints - fixed at top + headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + headerView.heightAnchor.constraint(equalToConstant: 39), + + // Picker constraints + picker.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16), + picker.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16), + picker.centerYAnchor.constraint(equalTo: headerView.centerYAnchor) + ]) + + // Adjust table view content to be below the header + // Don't use constraints on the table view itself since it's managed by UITableViewController + // Keep automatic adjustment for safe area but add our header height + tableView.contentInset.top = 39 + tableView.verticalScrollIndicatorInsets.top = 39 + + // Scroll to top to ensure content starts at the right position + tableView.setContentOffset(CGPoint(x: 0, y: -tableView.contentInset.top), animated: false) + } + + + private func loadInitialFolder() { + let lastFolderID = UserDefaults.standard.string(forKey: "MessageListLastFolderID") ?? "0" + + Task { + await loadFolders() + if let folder = allFolders.first(where: { $0.folderID == lastFolderID }) { + await MainActor.run { + setCurrentFolder(folder) + } + } + } + } override func setEditing(_ editing: Bool, animated: Bool) { // Takes care of toggling the button's title. @@ -167,16 +336,185 @@ final class MessageListViewController: TableViewController { if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - + // Toggle table view editing. tableView.setEditing(editing, animated: true) + + // Enable multiple selection in edit mode for bulk operations + tableView.allowsMultipleSelectionDuringEditing = editing + + // Show/hide toolbar with actions when in edit mode + if editing { + showEditToolbar() + } else { + hideEditToolbar() + } + } + + private func showEditToolbar() { + // Remove any existing toolbar first + editToolbar?.removeFromSuperview() + + // Create custom toolbar + let toolbar = UIToolbar() + toolbar.translatesAutoresizingMaskIntoConstraints = false + + // Add small fixed space on the left (8px) + let leftSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + leftSpace.width = 8 + + let moveButton = UIBarButtonItem( + title: LocalizedString("table-view.action.move"), + style: .plain, + target: self, + action: #selector(moveSelectedMessages) + ) + + let deleteButton = UIBarButtonItem( + title: LocalizedString("table-view.action.delete"), + style: .plain, + target: self, + action: #selector(deleteSelectedMessages) + ) + deleteButton.tintColor = .systemRed + + // Larger fixed space to push delete button to align with Settings tab + let rightSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil) + rightSpace.width = 55 // Adjusted to align with Settings tab + + let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + + toolbar.items = [leftSpace, moveButton, flexSpace, deleteButton, rightSpace] + + // Add toolbar to the view hierarchy + view.addSubview(toolbar) + + // Position toolbar above the tab bar + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + toolbar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + + editToolbar = toolbar + + // Adjust table view content inset to make room for toolbar + var contentInset = tableView.contentInset + contentInset.bottom = 44 // Standard toolbar height + tableView.contentInset = contentInset + tableView.scrollIndicatorInsets = contentInset + } + + private func hideEditToolbar() { + editToolbar?.removeFromSuperview() + editToolbar = nil + + // Reset table view content inset + var contentInset = tableView.contentInset + contentInset.bottom = 0 + tableView.contentInset = contentInset + tableView.scrollIndicatorInsets = contentInset + } + + @objc private func moveSelectedMessages() { + guard let selectedRows = tableView.indexPathsForSelectedRows, + !selectedRows.isEmpty else { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.no-selection.title"), + message: LocalizedString("private-messages-list.no-selection.message"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedString("ok"), style: .default)) + present(alert, animated: true) + return + } + + // Show folder picker for selected messages + showFolderPickerForMultiple(messages: selectedRows) + } + + @objc private func deleteSelectedMessages() { + guard let selectedRows = tableView.indexPathsForSelectedRows, + !selectedRows.isEmpty else { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.no-selection.title"), + message: LocalizedString("private-messages-list.no-selection.message"), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: LocalizedString("ok"), style: .default)) + present(alert, animated: true) + return + } + + let alert = UIAlertController( + title: LocalizedString("private-messages-list.delete-confirm.title"), + message: String(format: LocalizedString("private-messages-list.delete-confirm.message"), selectedRows.count), + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel)) + alert.addAction(UIAlertAction(title: LocalizedString("table-view.action.delete"), style: .destructive) { [weak self] _ in + for indexPath in selectedRows { + if let message = self?.dataSource?.message(at: indexPath) { + self?.deleteMessage(message) + } + } + self?.setEditing(false, animated: true) + }) + + present(alert, animated: true) + } + + private func showFolderPickerForMultiple(messages indexPaths: [IndexPath]) { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.move-folder.title"), + message: String(format: LocalizedString("private-messages-list.move-multiple.message"), indexPaths.count), + preferredStyle: .actionSheet + ) + + // Add folder options, excluding the current folder + for folder in allFolders where folder.folderID != currentFolder?.folderID { + let folderName: String + if folder.folderID == "0" { + folderName = LocalizedString("private-message-folder.inbox") + } else if folder.folderID == "-1" { + folderName = LocalizedString("private-message-folder.sent") + } else { + folderName = folder.name + } + + alert.addAction(UIAlertAction(title: folderName, style: .default) { [weak self] _ in + for indexPath in indexPaths { + if let message = self?.dataSource?.message(at: indexPath) { + self?.moveMessage(message, to: folder) + } + } + self?.setEditing(false, animated: true) + }) + } + + alert.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel)) + + // Configure for iPad + if let popover = alert.popoverPresentationController { + popover.barButtonItem = toolbarItems?.first + } + + present(alert, animated: true) } + override func themeDidChange() { super.themeDidChange() - + composeViewController?.themeDidChange() + folderPicker?.applyTheme(theme) + + if let headerView = folderPickerContainer { + headerView.backgroundColor = .clear + } + tableView.separatorColor = theme["listSeparatorColor"] } @@ -194,31 +532,32 @@ extension MessageListViewController { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // In edit mode, tapping should select/deselect for bulk operations, not open the message + if tableView.isEditing { + // Selection is handled automatically by the table view in edit mode + return + } + let message = dataSource!.message(at: indexPath) showMessage(message) } + // Disable swipe actions entirely since they don't work in edit mode + // and we don't want accidental deletions in normal mode override func tableView( _ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath ) -> UISwipeActionsConfiguration? { - if tableView.isEditing { - let delete = UIContextualAction(style: .destructive, title: LocalizedString("table-view.action.delete"), handler: { action, view, completion in - guard let message = self.dataSource?.message(at: indexPath) else { return } - self.deleteMessage(message) - completion(true) - }) - let config = UISwipeActionsConfiguration(actions: [delete]) - config.performsFirstActionWithFullSwipe = false - return config - } return nil } + + // Allow editing for all rows (for the selection circles in edit mode) + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return true + } override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - if tableView.isEditing { - return .delete - } + // Return .none to show selection circles instead of delete buttons when multiple selection is enabled return .none } } @@ -254,3 +593,22 @@ extension MessageListViewController: MessageListDataSourceDeletionDelegate { deleteMessage(message) } } + +extension MessageListViewController: MessageFolderPickerViewDelegate { + func folderPicker(_ picker: MessageFolderPickerView, didSelectFolder folder: PrivateMessageFolder) { + setCurrentFolder(folder) + refresh() + } + + func folderPickerDidRequestManageFolders(_ picker: MessageFolderPickerView) { + let manageFoldersVC = MessageFolderManagementViewController(managedObjectContext: managedObjectContext) + manageFoldersVC.onFoldersChanged = { [weak self] in + // Reload folders when management view makes changes + Task { + await self?.loadFolders() + } + } + let nav = NavigationController(rootViewController: manageFoldersVC) + present(nav, animated: true) + } +} diff --git a/App/View Controllers/Messages/MessageViewController.swift b/App/View Controllers/Messages/MessageViewController.swift index 2422a6826..2a9814a86 100644 --- a/App/View Controllers/Messages/MessageViewController.swift +++ b/App/View Controllers/Messages/MessageViewController.swift @@ -262,8 +262,9 @@ final class MessageViewController: ViewController { if message.seen == false { message.seen = true - try await message.managedObjectContext?.perform { - try message.managedObjectContext?.save() + let context = message.managedObjectContext + try await context?.perform { + try context?.save() } } } catch { diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index b32003490..bf9089085 100644 --- a/Awful.xcodeproj/project.pbxproj +++ b/Awful.xcodeproj/project.pbxproj @@ -201,6 +201,8 @@ 2D19BA3B29C33303009DD94F /* toot60.json in Resources */ = {isa = PBXBuildFile; fileRef = 2D19BA3829C33302009DD94F /* toot60.json */; }; 2D265F8C292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */; }; 2D265F8F292CB447001336ED /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 2D265F8E292CB447001336ED /* Lottie */; }; + 2D2E14412F0DE929003411D7 /* MessageFolderManagementViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2E143F2F0DE929003411D7 /* MessageFolderManagementViewController.swift */; }; + 2D2E14422F0DE929003411D7 /* MessageFolderPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D2E14402F0DE929003411D7 /* MessageFolderPickerView.swift */; }; 2D327DD627F468CE00D21AB0 /* BookmarkColorPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */; }; 2D921269292F588100B16011 /* platinum-member.png in Resources */ = {isa = PBXBuildFile; fileRef = 2D921268292F588100B16011 /* platinum-member.png */; }; 2DAF1FE12E05D3ED006F6BC4 /* View+FontDesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */; }; @@ -516,12 +518,13 @@ 2D19BA3729C33302009DD94F /* frogrefresh60.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = frogrefresh60.json; sourceTree = ""; }; 2D19BA3829C33302009DD94F /* toot60.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = toot60.json; sourceTree = ""; }; 2D265F8B292CB429001336ED /* GetOutFrogRefreshSpinnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetOutFrogRefreshSpinnerView.swift; sourceTree = ""; }; + 2D2E143F2F0DE929003411D7 /* MessageFolderManagementViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageFolderManagementViewController.swift; sourceTree = ""; }; + 2D2E14402F0DE929003411D7 /* MessageFolderPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageFolderPickerView.swift; sourceTree = ""; }; 2D327DD527F468CE00D21AB0 /* BookmarkColorPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkColorPicker.swift; sourceTree = ""; }; 2D921268292F588100B16011 /* platinum-member.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "platinum-member.png"; sourceTree = ""; }; 2DAF1FE02E05D3EB006F6BC4 /* View+FontDesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+FontDesign.swift"; sourceTree = ""; }; 2DD8209B25DDD9BF0015A90D /* CopyImageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyImageActivity.swift; sourceTree = ""; }; 30E0C5162E35C89D0030DC0A /* AnimatedImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; - 30E0C5172E35C89D0030DC0A /* SmilieData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieData.swift; sourceTree = ""; }; 30E0C5182E35C89D0030DC0A /* SmilieGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieGridItem.swift; sourceTree = ""; }; 30E0C5192E35C89D0030DC0A /* SmiliePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmiliePickerView.swift; sourceTree = ""; }; 30E0C51A2E35C89D0030DC0A /* SmilieSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmilieSearchViewModel.swift; sourceTree = ""; }; @@ -792,6 +795,8 @@ 1C29C3852258547C00E1217A /* Messages */ = { isa = PBXGroup; children = ( + 2D2E143F2F0DE929003411D7 /* MessageFolderManagementViewController.swift */, + 2D2E14402F0DE929003411D7 /* MessageFolderPickerView.swift */, 1C16FC131CCEE60C00C88BD1 /* MessageComposeViewController.swift */, 1CF6786D201E8F45009A9640 /* MessageListCell.swift */, 1CC256B01A38526B003FA7A8 /* MessageListViewController.swift */, @@ -1541,7 +1546,6 @@ 83410EF219A582B8002CD019 /* DateFormatters.swift in Sources */, 1C273A9E21B316DB002875A9 /* LoadMoreFooter.swift in Sources */, 1C2C1F0E1CE16FE200CD27DD /* CloseBBcodeTagCommand.swift in Sources */, - 30E0C51C2E35C89D0030DC0A /* SmilieData.swift in Sources */, 30E0C51D2E35C89D0030DC0A /* AnimatedImageView.swift in Sources */, 30E0C51E2E35C89D0030DC0A /* SmiliePickerView.swift in Sources */, 30E0C51F2E35C89D0030DC0A /* SmilieSearchViewModel.swift in Sources */, @@ -1561,6 +1565,8 @@ 1CFC996A1BD3F402001180A7 /* PostsPageRefreshArrowView.swift in Sources */, 1CE2B76819C2372200FDC33E /* LoginViewController.swift in Sources */, 1C16FBD51CBA91ED00C88BD1 /* PostsViewExternalStylesheetLoader.swift in Sources */, + 2D2E14412F0DE929003411D7 /* MessageFolderManagementViewController.swift in Sources */, + 2D2E14422F0DE929003411D7 /* MessageFolderPickerView.swift in Sources */, 1CB15BFB1A9EC9C800176E73 /* ReportPostViewController.swift in Sources */, 1C25AC4D1F5768D200977D6F /* ManagedObjectCountObserver.swift in Sources */, 1C25AC491F537A0B00977D6F /* WeakTrampoline.swift in Sources */, diff --git a/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/.xccurrentversion b/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/.xccurrentversion index 1bad827f2..3783b8c23 100644 --- a/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/.xccurrentversion +++ b/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Awful 6.3.xcdatamodel + Awful 7.11.xcdatamodel diff --git a/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/Awful 7.11.xcdatamodel/contents b/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/Awful 7.11.xcdatamodel/contents new file mode 100644 index 000000000..4f9ccb196 --- /dev/null +++ b/AwfulCore/Sources/AwfulCore/Model/Awful.xcdatamodeld/Awful 7.11.xcdatamodel/contents @@ -0,0 +1,198 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/AwfulCore/Sources/AwfulCore/Model/PrivateMessage.swift b/AwfulCore/Sources/AwfulCore/Model/PrivateMessage.swift index 98af8469b..f7890c2b6 100644 --- a/AwfulCore/Sources/AwfulCore/Model/PrivateMessage.swift +++ b/AwfulCore/Sources/AwfulCore/Model/PrivateMessage.swift @@ -10,6 +10,7 @@ public class PrivateMessage: AwfulManagedObject, Managed { @NSManaged public var forwarded: Bool @NSManaged public var innerHTML: String? + @NSManaged public var isSent: Bool @NSManaged var lastModifiedDate: Date @NSManaged public var messageID: String // When we scrape a folder of messages, we can't get at the "from" user's userID. rawFromUsername holds this unhelpful bit of data until we learn of the user's ID and can use the `from` relationship. @@ -19,12 +20,26 @@ public class PrivateMessage: AwfulManagedObject, Managed { @NSManaged public var sentDate: Date? @NSManaged public var sentDateRaw: String? @NSManaged public var subject: String? - + + @NSManaged public var folder: PrivateMessageFolder? @NSManaged internal var primitiveFrom: User? /* via sentPrivateMessages */ @NSManaged public var threadTag: ThreadTag? - @NSManaged var to: User? /* via receivedPrivateMessages */ + @NSManaged public var to: User? /* via receivedPrivateMessages */ public override var objectKey: PrivateMessageKey { .init(messageID: messageID) } + + public override func awakeFromInsert() { + super.awakeFromInsert() + + // Initialize required fields with default values + lastModifiedDate = Date() + + // These booleans have defaults in Core Data model but initialize them explicitly + forwarded = false + seen = false + replied = false + isSent = false + } } extension PrivateMessage { diff --git a/AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift b/AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift new file mode 100644 index 000000000..dd2efeaf6 --- /dev/null +++ b/AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift @@ -0,0 +1,47 @@ +// PrivateMessageFolder.swift +// +// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app + +import CoreData + +@objc(PrivateMessageFolder) +public class PrivateMessageFolder: AwfulManagedObject, Managed { + public static var entityName: String { "PrivateMessageFolder" } + + @NSManaged public var folderID: String + @NSManaged public var folderType: String + @NSManaged public var name: String + @NSManaged public var messages: Set + + public var isInbox: Bool { + folderID == "0" + } + + public var isSent: Bool { + folderID == "-1" + } + + public var isCustom: Bool { + !isInbox && !isSent + } +} + +@objc(PrivateMessageFolderKey) +public final class PrivateMessageFolderKey: AwfulObjectKey { + @objc public let folderID: String + + public init(folderID: String) { + self.folderID = folderID + super.init(entityName: PrivateMessageFolder.entityName) + } + + public required init?(coder: NSCoder) { + folderID = coder.decodeObject(forKey: folderIDKey) as! String + super.init(coder: coder) + } + + override var keys: [String] { + return [folderIDKey] + } +} +private let folderIDKey = "folderID" \ No newline at end of file diff --git a/AwfulCore/Sources/AwfulCore/Model/User.swift b/AwfulCore/Sources/AwfulCore/Model/User.swift index c91bea4df..82b17568e 100644 --- a/AwfulCore/Sources/AwfulCore/Model/User.swift +++ b/AwfulCore/Sources/AwfulCore/Model/User.swift @@ -31,6 +31,27 @@ public class User: AwfulManagedObject, Managed { public override var objectKey: UserKey { .init(userID: userID, username: username) } + + public override func awakeFromInsert() { + super.awakeFromInsert() + + // Initialize lastModifiedDate + lastModifiedDate = Date() + + // If userID is not set, create a placeholder based on username + // This happens when we only know the username (e.g., message recipients in Sent folder) + // The real userID will be updated when we encounter this user in a context where we have their ID + if userID == nil || userID.isEmpty { + if let name = username, !name.isEmpty { + // Create a deterministic placeholder ID based on username + // Prefix with "unknown-" to indicate this is a placeholder + userID = "unknown-\(name.lowercased().replacingOccurrences(of: " ", with: "_"))" + } else { + // Fallback to a UUID if we don't even have a username + userID = "unknown-\(UUID().uuidString)" + } + } + } } extension User { diff --git a/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift b/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift index 11f2774c9..800e59e15 100644 --- a/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift +++ b/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift @@ -1152,16 +1152,53 @@ public final class ForumsClient { // MARK: Private Messages public func listPrivateMessagesInInbox() async throws -> [PrivateMessage] { + return try await listPrivateMessagesInFolder(folderID: "0") + } + + public func listPrivateMessagesInFolder(folderID: String, page: Int = 1) async throws -> [PrivateMessage] { guard let mainContext = managedObjectContext, let backgroundContext = backgroundManagedObjectContext else { throw Error.missingManagedObjectContext } - let (data, response) = try await fetch(method: .get, urlString: "private.php", parameters: []) + var parameters: [String: Any] = ["folderid": folderID] + if page > 1 { + parameters["pagenumber"] = "\(page)" + } + + let (data, response) = try await fetch(method: .get, urlString: "private.php", parameters: parameters) let (document, url) = try parseHTML(data: data, response: response) let result = try PrivateMessageFolderScrapeResult(document, url: url) let backgroundMessages = try await backgroundContext.perform { - let messages = try result.upsert(into: backgroundContext) - try backgroundContext.save() + let messages = try result.upsert(into: backgroundContext, folderID: folderID) + do { + try backgroundContext.save() + } catch let error as NSError { + // Log detailed validation errors + print("Core Data Save Error when loading folder \(folderID): \(error)") + print("Error Code: \(error.code)") + print("Error Domain: \(error.domain)") + + if let detailedErrors = error.userInfo[NSDetailedErrorsKey] as? [NSError] { + print("Detailed Errors:") + for detailedError in detailedErrors { + print(" - Error Code: \(detailedError.code), Domain: \(detailedError.domain)") + print(" Description: \(detailedError.localizedDescription)") + if let entity = detailedError.userInfo[NSValidationObjectErrorKey] as? NSManagedObject { + print(" Entity: \(entity.entity.name ?? "unknown")") + } + if let key = detailedError.userInfo[NSValidationKeyErrorKey] as? String { + print(" Key: \(key)") + } + if let value = detailedError.userInfo[NSValidationValueErrorKey] { + print(" Value: \(value)") + } + if let predicate = detailedError.userInfo[NSValidationPredicateErrorKey] { + print(" Predicate: \(predicate)") + } + } + } + throw error + } return messages } return await mainContext.perform { @@ -1169,6 +1206,189 @@ public final class ForumsClient { } } + public func listPrivateMessageFolders() async throws -> [PrivateMessageFolder] { + guard let mainContext = managedObjectContext, + let backgroundContext = backgroundManagedObjectContext + else { throw Error.missingManagedObjectContext } + + let (data, response) = try await fetch(method: .get, urlString: "private.php", parameters: [:]) + let (document, url) = try parseHTML(data: data, response: response) + let result = try PrivateMessageFolderScrapeResult(document, url: url) + + let backgroundFolders = try await backgroundContext.perform { + var folders: [PrivateMessageFolder] = [] + + for folderInfo in result.allFolders { + let folder = PrivateMessageFolder.findOrCreate(in: backgroundContext, matching: .init("\(\PrivateMessageFolder.folderID) = \(folderInfo.id.rawValue)")) { + $0.folderID = folderInfo.id.rawValue + } + folder.name = folderInfo.name + + switch folderInfo.id.rawValue { + case "0": + folder.folderType = "inbox" + case "-1": + folder.folderType = "sent" + default: + folder.folderType = "custom" + } + + folders.append(folder) + } + + try backgroundContext.save() + return folders + } + + return await mainContext.perform { + backgroundFolders.compactMap { mainContext.object(with: $0.objectID) as? PrivateMessageFolder } + } + } + + public func createPrivateMessageFolder(name: String) async throws { + logger.info("[FOLDER_MGT] ForumsClient: Creating folder with name: '\(name)'") + + // First, get the edit folders page to retrieve current folder structure + logger.debug("[FOLDER_MGT] ForumsClient: Fetching edit folders page") + let (getPageData, getPageResponse) = try await fetch(method: .get, urlString: "private.php", parameters: ["action": "editfolders"]) + let (getPageDoc, _) = try parseHTML(data: getPageData, response: getPageResponse) + + // Parse existing folders and their IDs from the form + var folderListParams: [String: String] = [:] + var highestID = 0 + + // Find all existing folder inputs + let inputs = getPageDoc.nodes(matchingSelector: "input[name^='folderlist[']") + logger.debug("[FOLDER_MGT] ForumsClient: Found \(inputs.count) existing folder inputs") + + for input in inputs { + guard let nameAttr = input["name"], + let value = input["value"] else { continue } + + // Keep existing folders with their values + if !value.isEmpty { + folderListParams[nameAttr] = value + logger.debug("[FOLDER_MGT] ForumsClient: Existing folder: \(nameAttr) = '\(value)'") + } + + // Extract the ID number from folderlist[N] + if let startIndex = nameAttr.firstIndex(of: "["), + let endIndex = nameAttr.firstIndex(of: "]"), + startIndex < endIndex { + let idStr = String(nameAttr[nameAttr.index(after: startIndex)..