278 lines
14 KiB
Swift
278 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
/// Flattened tree entries give each account and folder its own native table row and drop target.
|
|
struct SidebarEntry: Identifiable, Equatable {
|
|
let id: String
|
|
var account: MailAccount?
|
|
var folder: MailFolder?
|
|
var depth = 0
|
|
var title: String
|
|
var icon: String
|
|
|
|
static func visible(accounts: [MailAccount], folders: [UUID: [MailFolder]], inbox: Bool, trash: Bool,
|
|
expanded: (String) -> Bool) -> [SidebarEntry] {
|
|
var entries: [SidebarEntry] = []
|
|
if inbox { entries.append(SidebarEntry(id: "unified:inbox", title: "Gemeinsamer Posteingang", icon: "tray.2")) }
|
|
if trash { entries.append(SidebarEntry(id: "unified:trash", title: "Gemeinsamer Papierkorb", icon: "trash")) }
|
|
func appendFolders(_ children: [MailFolder], depth: Int) {
|
|
for folder in children {
|
|
entries.append(SidebarEntry(id: folder.id, folder: folder, depth: depth,
|
|
title: folder.name, icon: folder.isTrash ? "trash" : folder.icon))
|
|
if expanded("folderExpanded." + folder.id), let children = folder.children {
|
|
appendFolders(children, depth: depth + 1)
|
|
}
|
|
}
|
|
}
|
|
for account in accounts {
|
|
entries.append(SidebarEntry(id: "account:" + account.id.uuidString, account: account,
|
|
title: account.name.isEmpty ? account.email : account.name, icon: "person.crop.circle"))
|
|
if expanded("accountExpanded." + account.id.uuidString) {
|
|
appendFolders(folders[account.id] ?? [], depth: 1)
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
static func destination(_ offset: Int, rows: [SidebarEntry], accounts: [MailAccount]) -> Int {
|
|
guard offset < rows.count else { return accounts.count }
|
|
// A drop within an account's folders goes before the next account, keeping the whole tree together.
|
|
let next = rows.dropFirst(max(0, offset)).compactMap(\.account).first
|
|
return next.flatMap { account in accounts.firstIndex { $0.id == account.id } } ?? accounts.count
|
|
}
|
|
}
|
|
|
|
|
|
struct MailSidebarList: NSViewRepresentable {
|
|
let store: MailStore
|
|
@Binding var selection: String?
|
|
let showInbox: Bool
|
|
let showTrash: Bool
|
|
let create: (UUID, MailFolder?) -> Void
|
|
let delete: (MailFolder) -> Void
|
|
let empty: (String) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scroll = NSScrollView()
|
|
scroll.hasVerticalScroller = true
|
|
scroll.drawsBackground = false
|
|
let table = NSTableView()
|
|
let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("sidebar"))
|
|
table.addTableColumn(column)
|
|
table.headerView = nil
|
|
table.backgroundColor = .clear
|
|
table.selectionHighlightStyle = .sourceList
|
|
table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle
|
|
table.intercellSpacing = NSSize(width: 0, height: 2)
|
|
table.delegate = context.coordinator
|
|
table.dataSource = context.coordinator
|
|
table.setDraggingSourceOperationMask(.move, forLocal: true)
|
|
table.registerForDraggedTypes([Coordinator.accountType, .string])
|
|
table.setAccessibilityIdentifier("accountSidebar")
|
|
let menu = NSMenu()
|
|
menu.delegate = context.coordinator
|
|
table.menu = menu
|
|
scroll.documentView = table
|
|
context.coordinator.table = table
|
|
return scroll
|
|
}
|
|
|
|
func updateNSView(_ scroll: NSScrollView, context: Context) {
|
|
context.coordinator.parent = self
|
|
context.coordinator.reload()
|
|
}
|
|
|
|
final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, NSMenuDelegate {
|
|
static let accountType = NSPasteboard.PasteboardType("de.mikemail.account-order")
|
|
var parent: MailSidebarList
|
|
weak var table: NSTableView?
|
|
var rows: [SidebarEntry] = []
|
|
var reloading = false
|
|
var displayedCounts: [String: Int] = [:]
|
|
|
|
init(_ parent: MailSidebarList) { self.parent = parent }
|
|
|
|
func expanded(_ key: String) -> Bool {
|
|
UserDefaults.standard.object(forKey: key) as? Bool ?? true
|
|
}
|
|
|
|
func reload(force: Bool = false) {
|
|
reloading = true
|
|
let updated = SidebarEntry.visible(accounts: parent.store.accounts, folders: parent.store.folders,
|
|
inbox: parent.showInbox, trash: parent.showTrash, expanded: expanded)
|
|
let counts = parent.store.unreadCounts
|
|
if force || updated != rows || counts != displayedCounts {
|
|
rows = updated
|
|
displayedCounts = counts
|
|
table?.reloadData()
|
|
}
|
|
if let index = rows.firstIndex(where: { $0.id == parent.selection }) {
|
|
if table?.selectedRow != index { table?.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false) }
|
|
} else if table?.selectedRow != -1 { table?.deselectAll(nil) }
|
|
reloading = false
|
|
}
|
|
|
|
func numberOfRows(in tableView: NSTableView) -> Int { rows.count }
|
|
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
|
|
rows[row].account == nil ? 30 : 46
|
|
}
|
|
func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
|
|
rows[row].folder?.selectable != false
|
|
}
|
|
func tableViewSelectionDidChange(_ notification: Notification) {
|
|
guard !reloading, let table, rows.indices.contains(table.selectedRow) else { return }
|
|
parent.selection = rows[table.selectedRow].id
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
|
let entry = rows[row]
|
|
let cell = NSTableCellView()
|
|
let stack = NSStackView()
|
|
stack.orientation = .horizontal
|
|
stack.distribution = .fill
|
|
stack.alignment = .centerY
|
|
stack.spacing = 8
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
cell.addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8 + CGFloat(entry.depth) * 12),
|
|
stack.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
|
|
stack.centerYAnchor.constraint(equalTo: cell.centerYAnchor)
|
|
])
|
|
let key = entry.account.map { "accountExpanded." + $0.id.uuidString }
|
|
?? (entry.folder?.children?.isEmpty == false ? "folderExpanded." + entry.id : nil)
|
|
if let key {
|
|
let button = NSButton(image: NSImage(systemSymbolName: expanded(key) ? "chevron.down" : "chevron.right",
|
|
accessibilityDescription: expanded(key) ? "Zuklappen" : "Aufklappen") ?? NSImage(),
|
|
target: self, action: #selector(toggle(_:)))
|
|
button.isBordered = false
|
|
button.identifier = NSUserInterfaceItemIdentifier(key)
|
|
button.setAccessibilityIdentifier(key)
|
|
button.widthAnchor.constraint(equalToConstant: 16).isActive = true
|
|
stack.addArrangedSubview(button)
|
|
} else {
|
|
let spacer = NSView()
|
|
spacer.widthAnchor.constraint(equalToConstant: 16).isActive = true
|
|
stack.addArrangedSubview(spacer)
|
|
}
|
|
let icon = NSImageView(image: NSImage(systemSymbolName: entry.icon, accessibilityDescription: nil) ?? NSImage())
|
|
let isTrash = entry.id == "unified:trash" || entry.folder?.isTrash == true
|
|
icon.contentTintColor = isTrash
|
|
? (NSColor.systemRed.blended(withFraction: 0.35, of: .black) ?? .systemRed)
|
|
: .controlAccentColor
|
|
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
|
|
stack.addArrangedSubview(icon)
|
|
let texts = NSStackView()
|
|
texts.orientation = .vertical
|
|
texts.alignment = .leading
|
|
texts.spacing = 2
|
|
let title = NSTextField(labelWithString: entry.title)
|
|
title.lineBreakMode = .byTruncatingTail
|
|
title.font = .systemFont(ofSize: 13)
|
|
texts.addArrangedSubview(title)
|
|
if let account = entry.account {
|
|
let email = NSTextField(labelWithString: account.email)
|
|
email.font = .systemFont(ofSize: 11)
|
|
email.textColor = .secondaryLabelColor
|
|
email.lineBreakMode = .byTruncatingTail
|
|
texts.addArrangedSubview(email)
|
|
cell.setAccessibilityIdentifier("accountRow-" + account.email)
|
|
}
|
|
texts.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
stack.addArrangedSubview(texts)
|
|
if entry.account == nil {
|
|
let count = parent.store.unreadCount(folderID: entry.id)
|
|
if count > 0 { stack.addArrangedSubview(NSTextField(labelWithString: String(count))) }
|
|
}
|
|
return cell
|
|
}
|
|
|
|
@objc func toggle(_ sender: NSButton) {
|
|
guard let key = sender.identifier?.rawValue else { return }
|
|
UserDefaults.standard.set(!expanded(key), forKey: key)
|
|
reload(force: true)
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
|
|
guard let account = rows[row].account else { return nil }
|
|
let item = NSPasteboardItem()
|
|
item.setString(account.id.uuidString, forType: Self.accountType)
|
|
return item
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo,
|
|
proposedRow row: Int, proposedDropOperation operation: NSTableView.DropOperation) -> NSDragOperation {
|
|
if info.draggingPasteboard.string(forType: Self.accountType) != nil {
|
|
tableView.setDropRow(row, dropOperation: .above)
|
|
return .move
|
|
}
|
|
if rows.indices.contains(row), rows[row].folder?.selectable == true,
|
|
let value = info.draggingPasteboard.string(forType: .string), !MailDrag.decode(value).isEmpty {
|
|
tableView.setDropRow(row, dropOperation: .on)
|
|
return .move
|
|
}
|
|
return []
|
|
}
|
|
|
|
func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo,
|
|
row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
|
|
if let value = info.draggingPasteboard.string(forType: Self.accountType),
|
|
let id = UUID(uuidString: value),
|
|
let source = parent.store.accounts.firstIndex(where: { $0.id == id }) {
|
|
parent.store.moveAccounts(from: IndexSet(integer: source),
|
|
to: SidebarEntry.destination(row, rows: rows, accounts: parent.store.accounts))
|
|
reload()
|
|
return true
|
|
}
|
|
guard rows.indices.contains(row), let folder = rows[row].folder,
|
|
folder.selectable, let value = info.draggingPasteboard.string(forType: .string) else { return false }
|
|
let ids = MailDrag.decode(value)
|
|
guard !ids.isEmpty else { return false }
|
|
Task { await parent.store.moveMessages(ids: ids, to: folder) }
|
|
return true
|
|
}
|
|
|
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
|
menu.removeAllItems()
|
|
guard let table, rows.indices.contains(table.clickedRow) else { return }
|
|
let row = rows[table.clickedRow]
|
|
func add(_ title: String, _ action: String, enabled: Bool = true) {
|
|
let item = NSMenuItem(title: title, action: #selector(menuAction(_:)), keyEquivalent: "")
|
|
item.target = self
|
|
item.representedObject = [row.id, action]
|
|
item.isEnabled = enabled
|
|
menu.addItem(item)
|
|
}
|
|
menu.autoenablesItems = false
|
|
if let account = row.account, let index = parent.store.accounts.firstIndex(where: { $0.id == account.id }) {
|
|
add("Neuen Ordner erstellen…", "create")
|
|
add("Konto nach oben", "up", enabled: index > 0)
|
|
add("Konto nach unten", "down", enabled: index + 1 < parent.store.accounts.count)
|
|
} else if let folder = row.folder {
|
|
add("Unterordner erstellen…", "create")
|
|
add("Ordner löschen…", "delete", enabled: !folder.isInbox)
|
|
if folder.isTrash { add("Papierkorb leeren…", "empty") }
|
|
} else if row.id == "unified:trash" { add("Papierkorb leeren…", "empty") }
|
|
}
|
|
|
|
@objc func menuAction(_ item: NSMenuItem) {
|
|
guard let values = item.representedObject as? [String], values.count == 2,
|
|
let row = rows.first(where: { $0.id == values[0] }) else { return }
|
|
switch values[1] {
|
|
case "create":
|
|
if let account = row.account { parent.create(account.id, nil) }
|
|
else if let folder = row.folder { parent.create(folder.accountID, folder) }
|
|
case "delete": if let folder = row.folder { parent.delete(folder) }
|
|
case "empty": parent.empty(row.id)
|
|
case "up", "down":
|
|
if let account = row.account, let index = parent.store.accounts.firstIndex(where: { $0.id == account.id }) {
|
|
parent.store.moveAccounts(from: IndexSet(integer: index), to: values[1] == "up" ? index - 1 : index + 2)
|
|
reload()
|
|
}
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
}
|