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 9c9333fa9..dfebe5d7a 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -1093,6 +1093,193 @@ } } }, + "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.delete-confirm-title" : { + "comment" : "Title of confirmation dialog when deleting a folder", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete Folder?" + } + } + } + }, + "private-message-folder.delete-confirm-message" : { + "comment" : "Message shown when deleting a folder explaining that messages will be moved", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Messages in this folder will be moved to Inbox or Sent." + } + } + } + }, + "private-message-folder.delete-button" : { + "comment" : "Button to confirm folder deletion", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" + } + } + } + }, + "private-message-folder.footer-editing" : { + "comment" : "Footer text shown in folder management when editing mode is active", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the minus button to delete a folder." + } + } + } + }, + "private-message-folder.footer-normal" : { + "comment" : "Footer text shown in folder management when not in editing mode", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap Edit to delete folders." + } + } + } + }, + "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", @@ -1177,6 +1364,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", @@ -1201,6 +1410,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" : { }, @@ -1440,6 +1715,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", diff --git a/App/View Controllers/Messages/MessageFolderManagementViewController.swift b/App/View Controllers/Messages/MessageFolderManagementViewController.swift new file mode 100644 index 000000000..ce0d96008 --- /dev/null +++ b/App/View Controllers/Messages/MessageFolderManagementViewController.swift @@ -0,0 +1,298 @@ +// 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") + +/// Maximum folder name length allowed by the SA Forums API +private let maxFolderNameLength = 25 + +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) + ) + + addButton = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addButtonTapped) + ) + updateNavigationItems() + + loadFolders() + } + + private var addButton: UIBarButtonItem! + + private func updateNavigationItems() { + // Edit only makes sense when there's something to delete. + navigationItem.rightBarButtonItems = folders.isEmpty ? [addButton] : [addButton, editButtonItem] + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + tableView.setEditing(editing, animated: animated) + + // Footer copy differs between normal and edit mode. + if tableView.footerView(forSection: 0) != nil { + tableView.reloadSections(IndexSet(integer: 0), with: .none) + } + } + + private func loadFolders() { + Task { + do { + let allFolders = try await ForumsClient.shared.listPrivateMessageFolders() + await MainActor.run { + self.folders = allFolders.filter { $0.isCustom } + self.tableView.reloadData() + self.updateNavigationItems() + if self.folders.isEmpty, self.isEditing { + self.setEditing(false, animated: true) + } + } + } catch { + logger.error("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 <= maxFolderNameLength else { return } + self?.createFolder(name: folderName) + } + 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) { + if let text = textField.text, text.count > maxFolderNameLength { + textField.text = String(text.prefix(maxFolderNameLength)) + } + + guard let alertController = presentedViewController as? UIAlertController else { return } + let text = textField.text ?? "" + alertController.actions.first?.isEnabled = !text.isEmpty && text.count <= maxFolderNameLength + } + + private func createFolder(name: String) { + Task { + do { + try await ForumsClient.shared.createPrivateMessageFolder(name: name) + loadFolders() + await MainActor.run { [weak self] in + self?.onFoldersChanged?() + } + } catch { + logger.error("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] + + let alert = UIAlertController( + title: LocalizedString("private-message-folder.delete-confirm-title"), + message: LocalizedString("private-message-folder.delete-confirm-message"), + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel)) + + alert.addAction(UIAlertAction( + title: LocalizedString("private-message-folder.delete-button"), + style: .destructive + ) { [weak self] _ in + self?.performFolderDeletion(folder: folder, at: indexPath) + }) + + present(alert, animated: true) + } + + private func performFolderDeletion(folder: PrivateMessageFolder, at indexPath: IndexPath) { + Task { + do { + // The server does NOT automatically move messages when deleting a folder; + // they remain in a ghost folder accessible by ID but invisible in the UI. + // Manually move each message out before deleting the folder itself. + let messages = try await ForumsClient.shared.listPrivateMessagesInFolder(folderID: folder.folderID) + + let currentUsername = UserDefaults.standard.string(forKey: "username") + + for message in messages { + let wasSentByCurrentUser = message.from?.username == currentUsername + let targetFolderID = wasSentByCurrentUser ? "-1" : "0" + try await ForumsClient.shared.movePrivateMessage(message, toFolderID: targetFolderID) + } + + try await ForumsClient.shared.deletePrivateMessageFolder(folderID: folder.folderID, folderName: folder.name) + + await MainActor.run { [weak self] in + guard let self else { return } + self.folders.remove(at: indexPath.row) + self.tableView.deleteRows(at: [indexPath], with: .automatic) + self.updateNavigationItems() + if self.folders.isEmpty, self.isEditing { + self.setEditing(false, animated: true) + } + self.onFoldersChanged?() + } + } catch { + logger.error("Failed to delete folder '\(folder.name)': \(error)") + await MainActor.run { [weak self] in + let alert = UIAlertController( + title: LocalizedString("private-message-folder.delete-error-title"), + error: error + ) + self?.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 LocalizedString("private-message-folder.footer-editing") + } + return LocalizedString("private-message-folder.footer-normal") + } + + 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 { + return tableView.isEditing + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + deleteFolder(at: indexPath) + } + } + + // Disabled — deletion is only allowed in edit mode. + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + return nil + } +} diff --git a/App/View Controllers/Messages/MessageFolderPickerView.swift b/App/View Controllers/Messages/MessageFolderPickerView.swift new file mode 100644 index 000000000..0bcfe9df9 --- /dev/null +++ b/App/View Controllers/Messages/MessageFolderPickerView.swift @@ -0,0 +1,264 @@ +// 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 allFolders: [PrivateMessageFolder] = [] + private var currentFolder: PrivateMessageFolder? + + 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) + + // Detect taps on segment 2 when it's already selected so the menu re-opens. + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleSegmentTap(_:))) + tapGesture.delegate = self + segmentedControl.addGestureRecognizer(tapGesture) + + segmentedControl.apportionsSegmentWidthsByContent = true + + backgroundColor = .clear + 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 = allFolders.first(where: { $0.folderID == "0" }) { + currentFolder = folder + delegate?.folderPicker(self, didSelectFolder: folder) + } + case 1: + if let folder = allFolders.first(where: { $0.folderID == "-1" }) { + currentFolder = folder + delegate?.folderPicker(self, didSelectFolder: folder) + } + case 2: + // If segment 2 currently displays a custom folder name, reselect that folder. + // Otherwise open the folders menu. + let segmentTitle = segmentedControl.titleForSegment(at: 2) ?? "" + let defaultTitle = LocalizedString("private-message-folder.more") + + if segmentTitle != defaultTitle, + let customFolder = allFolders.first(where: { $0.name == segmentTitle && $0.isCustom }) { + currentFolder = customFolder + delegate?.folderPicker(self, didSelectFolder: customFolder) + } else { + showFoldersMenu() + } + default: + break + } + } + + private func showFoldersMenu() { + guard let viewController = findViewController() else { + restorePreviousSelection() + return + } + + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + let menuAppearance = Theme.defaultTheme()[string: "menuAppearance"] + alertController.overrideUserInterfaceStyle = menuAppearance == "light" ? .light : .dark + + for folder in allFolders where folder.isCustom { + 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) + }) + } + + 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) + }) + + alertController.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel) { [weak self] _ in + self?.restorePreviousSelection() + }) + + if let popover = alertController.popoverPresentationController { + popover.sourceView = segmentedControl + let segmentWidth = segmentedControl.bounds.width / CGFloat(segmentedControl.numberOfSegments) + popover.sourceRect = CGRect(x: segmentWidth * 2, y: 0, width: segmentWidth, height: segmentedControl.bounds.height) + popover.permittedArrowDirections = [.up] + popover.delegate = self + } + + 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: + // Custom folder - keep segment 2 selected (shows folder name) + self.segmentedControl.selectedSegmentIndex = 2 + } + } + } + } + + func updateFolders(_ folders: [PrivateMessageFolder]) { + self.allFolders = folders + + if let current = currentFolder { + if folders.contains(where: { $0.folderID == current.folderID }) { + selectFolder(current) + } else { + // Current folder was deleted — fall back to the inbox and reset segment 2's 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) + } + } + } + + // If no custom folders remain, reset segment 2 back to the default "Folders" label. + let hasCustomFolders = folders.contains { $0.isCustom } + if !hasCustomFolders, segmentedControl.numberOfSegments == 3, + 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 + 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) { + // The gesture delegate has already filtered taps to segment 2. + if segmentedControl.selectedSegmentIndex == 2 { + showFoldersMenu() + } + } +} + +// MARK: - UIGestureRecognizerDelegate +extension MessageFolderPickerView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard segmentedControl.selectedSegmentIndex == 2 else { return false } + + // apportionsSegmentWidthsByContent gives variable widths, so locate the tap by summing segment 0 & 1. + let location = touch.location(in: segmentedControl) + let width0 = segmentedControl.widthForSegment(at: 0) + let width1 = segmentedControl.widthForSegment(at: 1) + + if width0 == 0 || width1 == 0 { + let segmentWidth = segmentedControl.bounds.width / 3.0 + return location.x >= segmentWidth * 2 + } + return location.x >= width0 + width1 + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return false + } +} + +extension MessageFolderPickerView: UIPopoverPresentationControllerDelegate { + func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { + restorePreviousSelection() + } +} diff --git a/App/View Controllers/Messages/MessageListViewController.swift b/App/View Controllers/Messages/MessageListViewController.swift index b7d6f4d4f..4bd79dacf 100644 --- a/App/View Controllers/Messages/MessageListViewController.swift +++ b/App/View Controllers/Messages/MessageListViewController.swift @@ -11,6 +11,16 @@ import UIKit private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MessageListViewController") +// MARK: - Layout Constants +private enum LayoutConstants { + static let folderPickerHeight: CGFloat = 39 +} + +// MARK: - UserDefaults Keys +private enum UserDefaultsKey { + static let lastFolderID = "MessageListLastFolderID" +} + @objc(MessageListViewController) final class MessageListViewController: TableViewController { @@ -20,7 +30,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 currentFolder: PrivateMessageFolder? + private var allFolders: [PrivateMessageFolder] = [] + private var editToolbar: UIToolbar? + init(managedObjectContext: NSManagedObjectContext) { self.managedObjectContext = managedObjectContext super.init(nibName: nil, bundle: nil) @@ -58,7 +72,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 } @@ -94,11 +109,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) @@ -108,6 +129,34 @@ 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) + + let currentFolderRemoved = currentFolder.map { c in !folders.contains(where: { $0.folderID == c.folderID }) } ?? true + if currentFolderRemoved, 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.folderID != currentFolder?.folderID else { return } + currentFolder = folder + folderPicker?.selectFolder(folder) + + dataSource = makeDataSource() + tableView.reloadData() + + UserDefaults.standard.set(folder.folderID, forKey: UserDefaultsKey.lastFolderID) + } func showMessage(_ message: PrivateMessage, pendingRestoration: PendingMessageRestoration? = nil) { if enableHaptics { @@ -138,6 +187,72 @@ final class MessageListViewController: TableViewController { } } + private func showFolderPicker(for message: PrivateMessage) { + let alert = buildFolderPickerAlert( + message: LocalizedString("private-messages-list.move-folder.message") + ) { [weak self] folder in + self?.moveMessage(message, to: folder) + } + + 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 buildFolderPickerAlert( + message: String, + onFolderSelected: @escaping (PrivateMessageFolder) -> Void + ) -> UIAlertController { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.move-folder.title"), + message: message, + preferredStyle: .actionSheet + ) + + for folder in allFolders where folder.folderID != currentFolder?.folderID { + alert.addAction(UIAlertAction(title: displayName(for: folder), style: .default) { _ in + onFolderSelected(folder) + }) + } + + alert.addAction(UIAlertAction(title: LocalizedString("cancel"), style: .cancel)) + + return alert + } + + private func moveMessage(_ message: PrivateMessage, to folder: PrivateMessageFolder) { + Task { + do { + try await ForumsClient.shared.movePrivateMessage(message, toFolderID: folder.folderID) + // The message disappears from the current folder view via the NSFetchedResultsController predicate. + } catch { + if visible { + let alert = UIAlertController( + title: LocalizedString("private-messages-list.move-error.title"), + error: error + ) + present(alert, animated: true) + } + } + } + } + + private func displayName(for folder: PrivateMessageFolder) -> String { + switch folder.folderID { + case "0": + return LocalizedString("private-message-folder.inbox") + case "-1": + return LocalizedString("private-message-folder.sent") + default: + return folder.name + } + } + private func recalculateSeparatorInset() { tableView.separatorInset.left = MessageListCell.separatorLeftInset( showsTagAndRating: showThreadTags, @@ -149,10 +264,14 @@ final class MessageListViewController: TableViewController { override func viewDidLoad() { super.viewDidLoad() - + + setupFolderPicker() + tableView.estimatedRowHeight = 65 recalculateSeparatorInset() + loadInitialFolder() + dataSource = makeDataSource() tableView.reloadData() @@ -160,66 +279,254 @@ final class MessageListViewController: TableViewController { self.refresh() } } + + private func setupFolderPicker() { + let picker = MessageFolderPickerView() + picker.delegate = self + picker.applyTheme(theme) + picker.translatesAutoresizingMaskIntoConstraints = false + folderPicker = picker + + // Host the picker in a tableHeaderView so it scrolls with the list and pull-to-refresh + // uses its natural threshold (a pinned overlay + contentInset.top offsets PullToRefresh's + // trigger distance). + let container = UIView(frame: CGRect(x: 0, y: 0, width: view.bounds.width, height: LayoutConstants.folderPickerHeight)) + container.autoresizingMask = .flexibleWidth + container.backgroundColor = .clear + container.addSubview(picker) + + NSLayoutConstraint.activate([ + picker.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + picker.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16), + picker.centerYAnchor.constraint(equalTo: container.centerYAnchor), + ]) + + tableView.tableHeaderView = container + } + + private func loadInitialFolder() { + let lastFolderID = UserDefaults.standard.string(forKey: UserDefaultsKey.lastFolderID) ?? "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. super.setEditing(editing, animated: true) if enableHaptics { UIImpactFeedbackGenerator(style: .medium).impactOccurred() } - - // Toggle table view editing. + tableView.setEditing(editing, animated: true) + tableView.allowsMultipleSelectionDuringEditing = editing + + if editing { + showEditToolbar() + } else { + hideEditToolbar() + } } - + + private var editToolbarMoveButton: UIBarButtonItem? + private let editToolbarHeight: CGFloat = 44 + + private func showEditToolbar() { + editToolbar?.removeFromSuperview() + + // Host the toolbar on the nav controller's view rather than `view` (which is the + // tableView in a UITableViewController), since autolayout subviews of a UIScrollView + // don't receive width constraints reliably. + guard let host = navigationController?.view else { return } + + let toolbar = UIToolbar() + toolbar.translatesAutoresizingMaskIntoConstraints = false + + let moveButton = UIBarButtonItem( + title: LocalizedString("table-view.action.move"), + style: .plain, + target: self, + action: #selector(moveSelectedMessages) + ) + moveButton.tintColor = theme[uicolor: "tintColor"] + editToolbarMoveButton = moveButton + + let deleteButton = UIBarButtonItem( + title: LocalizedString("table-view.action.delete"), + style: .plain, + target: self, + action: #selector(deleteSelectedMessages) + ) + deleteButton.tintColor = .systemRed + + let flexSpace1 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let flexSpace2 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let flexSpace3 = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + toolbar.setItems([flexSpace1, moveButton, flexSpace2, deleteButton, flexSpace3], animated: false) + + host.addSubview(toolbar) + + NSLayoutConstraint.activate([ + toolbar.leadingAnchor.constraint(equalTo: host.leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: host.trailingAnchor), + toolbar.bottomAnchor.constraint(equalTo: host.safeAreaLayoutGuide.bottomAnchor), + toolbar.heightAnchor.constraint(equalToConstant: editToolbarHeight), + ]) + + editToolbar = toolbar + + var contentInset = tableView.contentInset + contentInset.bottom = editToolbarHeight + tableView.contentInset = contentInset + tableView.scrollIndicatorInsets = contentInset + } + + private func hideEditToolbar() { + editToolbar?.removeFromSuperview() + editToolbar = nil + editToolbarMoveButton = nil + + 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 + } + + 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 + // Collect messages up-front since deleting rows invalidates index paths. + let messages = selectedRows.compactMap { self?.dataSource?.message(at: $0) } + for message in messages { + self?.deleteMessage(message) + } + self?.setEditing(false, animated: true) + }) + + present(alert, animated: true) + } + + private func showFolderPickerForMultiple(messages indexPaths: [IndexPath]) { + let alert = buildFolderPickerAlert( + message: String(format: LocalizedString("private-messages-list.move-multiple.message"), indexPaths.count) + ) { [weak self] folder in + // Collect messages up-front since moves shift index paths. + let messages = indexPaths.compactMap { self?.dataSource?.message(at: $0) } + for message in messages { + self?.moveMessage(message, to: folder) + } + self?.setEditing(false, animated: true) + } + + if let popover = alert.popoverPresentationController { + if let moveButton = editToolbarMoveButton { + popover.barButtonItem = moveButton + } else { + popover.sourceView = tableView + popover.sourceRect = CGRect(x: tableView.bounds.midX, y: tableView.bounds.midY, width: 0, height: 0) + } + } + + present(alert, animated: true) + } + override func themeDidChange() { super.themeDidChange() - + composeViewController?.themeDidChange() + folderPicker?.applyTheme(theme) + tableView.separatorColor = theme["listSeparatorColor"] } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - + refreshIfNecessary() } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // The toolbar lives on navigationController.view, so leaving edit mode + // before navigating away prevents it from lingering on other screens. + if isEditing { + setEditing(false, animated: false) + } + } } // MARK: UITableViewDelegate extension MessageListViewController { override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return dataSource!.tableView(tableView, heightForRowAt: indexPath) + guard let dataSource else { return UITableView.automaticDimension } + return dataSource.tableView(tableView, heightForRowAt: indexPath) } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let message = dataSource!.message(at: indexPath) + // In edit mode the tap is a selection toggle, not a navigation action. + if tableView.isEditing { return } + + guard let dataSource else { return } + let message = dataSource.message(at: indexPath) showMessage(message) } + // Swipe-to-delete is disabled — destructive actions must go through edit 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 } - + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + return true + } + + // .none gives selection circles in edit mode instead of the default delete button. override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { - if tableView.isEditing { - return .delete - } return .none } } @@ -239,6 +546,22 @@ extension MessageListViewController: MessageListDataSourceDeletionDelegate { } } +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 + Task { await self?.loadFolders() } + } + let nav = NavigationController(rootViewController: manageFoldersVC) + present(nav, animated: true) + } +} + extension MessageListViewController: RestorableLocation { var restorationRoute: AwfulRoute? { .messagesList diff --git a/App/View Controllers/Messages/MessageViewController.swift b/App/View Controllers/Messages/MessageViewController.swift index 5ed423c12..45dc1caae 100644 --- a/App/View Controllers/Messages/MessageViewController.swift +++ b/App/View Controllers/Messages/MessageViewController.swift @@ -300,7 +300,10 @@ final class MessageViewController: ViewController { if message.seen == false { message.seen = true - try message.managedObjectContext?.save() + let context = message.managedObjectContext + try await context?.perform { + try context?.save() + } } } catch { title = "" diff --git a/Awful.xcodeproj/project.pbxproj b/Awful.xcodeproj/project.pbxproj index 676f0e473..2e5364072 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 */; }; 2D3CB31E2EBF09C300BD4A12 /* five_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3102EBF09C300BD4A12 /* five_appicon.icon */; }; 2D3CB31F2EBF09C300BD4A12 /* smith_appicon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 2D3CB3192EBF09C300BD4A12 /* smith_appicon.icon */; }; @@ -546,6 +548,8 @@ 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 = ""; }; 2D3CB30F2EBF09C300BD4A12 /* cute_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = cute_appicon.icon; sourceTree = ""; }; 2D3CB3102EBF09C300BD4A12 /* five_appicon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = five_appicon.icon; sourceTree = ""; }; @@ -856,6 +860,8 @@ 1C29C3852258547C00E1217A /* Messages */ = { isa = PBXGroup; children = ( + 2D2E143F2F0DE929003411D7 /* MessageFolderManagementViewController.swift */, + 2D2E14402F0DE929003411D7 /* MessageFolderPickerView.swift */, 1C16FC131CCEE60C00C88BD1 /* MessageComposeViewController.swift */, 2D3D26022F85E80100862514 /* PrivateMessageDraft.swift */, 1CF6786D201E8F45009A9640 /* MessageListCell.swift */, @@ -1665,6 +1671,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 493ac93ac..c9f871ac3 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,19 @@ 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() + + lastModifiedDate = Date() + } } extension PrivateMessage { diff --git a/AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift b/AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift new file mode 100644 index 000000000..0de716213 --- /dev/null +++ b/AwfulCore/Sources/AwfulCore/Model/PrivateMessageFolder.swift @@ -0,0 +1,58 @@ +// 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 + } + + public override func awakeFromInsert() { + super.awakeFromInsert() + + folderID = "" + folderType = "" + name = "" + } +} + +@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) { + guard let id = coder.decodeObject(forKey: folderIDKey) as? String else { + return nil + } + folderID = id + super.init(coder: coder) + } + + override var keys: [String] { + return [folderIDKey] + } +} +private let folderIDKey = "folderID" diff --git a/AwfulCore/Sources/AwfulCore/Model/User.swift b/AwfulCore/Sources/AwfulCore/Model/User.swift index 1fe3e0f71..bf46dd007 100644 --- a/AwfulCore/Sources/AwfulCore/Model/User.swift +++ b/AwfulCore/Sources/AwfulCore/Model/User.swift @@ -31,20 +31,75 @@ public class User: AwfulManagedObject, Managed { public override var objectKey: UserKey { .init(userID: userID, username: username) } + + public override func awakeFromInsert() { + super.awakeFromInsert() + + lastModifiedDate = Date() + + // When we only know the username (e.g. message recipients in the Sent folder) + // we still need a stable userID. Generate a placeholder that can be reconciled + // once we encounter the same user in a context where we have their real ID. + if userID.isEmpty { + if let name = username, !name.isEmpty { + userID = "\(User.placeholderIDPrefix)\(name.lowercased().replacingOccurrences(of: " ", with: "_"))" + } else { + userID = "\(User.placeholderIDPrefix)\(UUID().uuidString)" + } + } + } + + static let placeholderIDPrefix = "unknown-" + + /// True while this user's `userID` is a synthetic placeholder generated from its username. + public var hasPlaceholderID: Bool { userID.hasPrefix(User.placeholderIDPrefix) } } extension User { public var avatarURL: URL? { return customTitleHTML.flatMap(extractAvatarURL) } + + /// Folds any placeholder `User` rows that share this user's username into `self`. + /// + /// Call after assigning a real `userID` to a user (e.g. after scraping their profile or a + /// post that identifies them). Placeholders are created by the Sent-folder PM scrape when + /// only a recipient's username is known; without this step they would linger in Core Data + /// indefinitely, accumulating duplicates alongside the real `User` row. + func absorbPlaceholders() { + guard let context = managedObjectContext, + let username, !username.isEmpty, + !hasPlaceholderID + else { return } + + let placeholders = User.fetch(in: context) { + $0.predicate = NSPredicate( + format: "%K = %@ AND %K BEGINSWITH %@ AND self != %@", + #keyPath(User.username), username, + #keyPath(User.userID), User.placeholderIDPrefix, + self + ) + } + guard !placeholders.isEmpty else { return } + + for placeholder in placeholders { + receivedPrivateMessages.formUnion(placeholder.receivedPrivateMessages) + sentPrivateMessages.formUnion(placeholder.sentPrivateMessages) + posts.formUnion(placeholder.posts) + threads.formUnion(placeholder.threads) + threadFilters.formUnion(placeholder.threadFilters) + announcements.formUnion(placeholder.announcements) + context.delete(placeholder) + } + } } // TODO: this is very stupid, just handle it during scraping public func extractAvatarURL(fromCustomTitleHTML customTitleHTML: String) -> URL? { let document = HTMLDocument(string: customTitleHTML) - let img = document.firstNode(matchingParsedSelector: .cached("div > img:first-child")) ?? - document.firstNode(matchingParsedSelector: .cached("body > img:first-child")) ?? - document.firstNode(matchingParsedSelector: .cached("a > img:first-child")) + let img = document.firstNode(matchingParsedSelector: .cached("div > img:first-of-type")) ?? + document.firstNode(matchingParsedSelector: .cached("body > img:first-of-type")) ?? + document.firstNode(matchingParsedSelector: .cached("a > img:first-of-type")) let src = img?["data-cfsrc"] ?? img?["src"] return src.flatMap { URL(string: $0) } diff --git a/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift b/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift index 926ed2db4..bd0618f6b 100644 --- a/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift +++ b/AwfulCore/Sources/AwfulCore/Networking/ForumsClient.swift @@ -1481,16 +1481,36 @@ 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 { + // Core Data validation errors bury the per-attribute details inside NSDetailedErrorsKey. + let detailed = (error.userInfo[NSDetailedErrorsKey] as? [NSError])?.map { detail -> String in + let entity = (detail.userInfo[NSValidationObjectErrorKey] as? NSManagedObject)?.entity.name ?? "?" + let key = detail.userInfo[NSValidationKeyErrorKey] as? String ?? "?" + return "\(entity).\(key): \(detail.localizedDescription)" + }.joined(separator: "; ") + logger.error("Failed to save folder \(folderID): \(error) [\(detailed ?? "")]") + throw error + } return messages } return await mainContext.perform { @@ -1498,6 +1518,169 @@ 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 { + // First, get the edit folders page to retrieve current folder structure + 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[']") + + 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 + } + + // 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)..