diff --git a/Pachyderm/Model/Hashtag.swift b/Pachyderm/Model/Hashtag.swift index e77d5989..ff990359 100644 --- a/Pachyderm/Model/Hashtag.swift +++ b/Pachyderm/Model/Hashtag.swift @@ -8,7 +8,7 @@ import Foundation -public class Hashtag: Decodable { +public class Hashtag: Codable { public let name: String public let url: URL public let history: [History]? @@ -27,7 +27,7 @@ public class Hashtag: Decodable { } extension Hashtag { - public class History: Decodable { + public class History: Codable { public let day: Date public let uses: Int public let accounts: Int @@ -42,7 +42,7 @@ extension Hashtag { extension Hashtag: Equatable, Hashable { public static func ==(lhs: Hashtag, rhs: Hashtag) -> Bool { - return lhs.url == rhs.url + return lhs.name == rhs.name } public func hash(into hasher: inout Hasher) { diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 0994adbe..a13a6170 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -164,6 +164,9 @@ D68632AB21ED8319008C716E /* GMImagePickerController.h in Headers */ = {isa = PBXBuildFile; fileRef = D686329121ED8319008C716E /* GMImagePickerController.h */; settings = {ATTRIBUTES = (Public, ); }; }; D68632AC21ED8319008C716E /* GMImagePicker.strings in Resources */ = {isa = PBXBuildFile; fileRef = D686329321ED8319008C716E /* GMImagePicker.strings */; }; D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */; }; + D6945C2F23AC47C3005C403C /* SavedHashtagsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C2E23AC47C3005C403C /* SavedHashtagsManager.swift */; }; + D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */; }; + D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */; }; D6A3BC7723218E1300FD64D5 /* TimelineSegment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */; }; D6A3BC7923218E9200FD64D5 /* NotificationGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */; }; D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */; }; @@ -435,6 +438,9 @@ D686329121ED8319008C716E /* GMImagePickerController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GMImagePickerController.h; sourceTree = ""; }; D686329421ED8319008C716E /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = GMImagePicker.strings; sourceTree = ""; }; D68FEC4E232C5BC300C84F23 /* SegmentedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentedPageViewController.swift; sourceTree = ""; }; + D6945C2E23AC47C3005C403C /* SavedHashtagsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtagsManager.swift; sourceTree = ""; }; + D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineViewController.swift; sourceTree = ""; }; + D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddSavedHashtagViewController.swift; sourceTree = ""; }; D6A3BC7323218C6E00FD64D5 /* TimelineSegment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSegment.swift; sourceTree = ""; }; D6A3BC7823218E9200FD64D5 /* NotificationGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationGroup.swift; sourceTree = ""; }; D6A3BC7A232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNotificationGroupTableViewCell.swift; sourceTree = ""; }; @@ -731,6 +737,7 @@ isa = PBXGroup; children = ( D627943D23A564D400D38C68 /* ExploreViewController.swift */, + D6945C3323AC6431005C403C /* AddSavedHashtagViewController.swift */, ); path = Explore; sourceTree = ""; @@ -781,6 +788,7 @@ D641C782213DD7F0004B4513 /* Main */, D641C783213DD7FE004B4513 /* Onboarding */, D641C781213DD7DD004B4513 /* Timeline */, + D6945C3023AC4D21005C403C /* Hashtag Timeline */, D641C784213DD819004B4513 /* Profile */, D641C785213DD83B004B4513 /* Conversation */, D641C786213DD852004B4513 /* Notifications */, @@ -1057,6 +1065,14 @@ path = de.lproj; sourceTree = ""; }; + D6945C3023AC4D21005C403C /* Hashtag Timeline */ = { + isa = PBXGroup; + children = ( + D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */, + ); + path = "Hashtag Timeline"; + sourceTree = ""; + }; D6A3BC7223218C6E00FD64D5 /* Utilities */ = { isa = PBXGroup; children = ( @@ -1209,6 +1225,7 @@ D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */, D627FF75217E923E00CC0648 /* DraftsManager.swift */, + D6945C2E23AC47C3005C403C /* SavedHashtagsManager.swift */, D6028B9A2150811100F223B9 /* MastodonCache.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6F1F84E2193B9BE00F5FE67 /* Caching */, @@ -1641,6 +1658,7 @@ 0411610022B442870030A9B7 /* AttachmentViewController.swift in Sources */, D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */, D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */, + D6945C3423AC6431005C403C /* AddSavedHashtagViewController.swift in Sources */, D627943723A552C200D38C68 /* BookmarkStatusActivity.swift in Sources */, D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */, D66362712136338600C9CBA2 /* ComposeViewController.swift in Sources */, @@ -1680,6 +1698,7 @@ 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, + D6945C2F23AC47C3005C403C /* SavedHashtagsManager.swift in Sources */, D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */, D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */, D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */, @@ -1703,6 +1722,7 @@ D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */, + D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */, D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */, diff --git a/Tusker/SavedHashtagsManager.swift b/Tusker/SavedHashtagsManager.swift new file mode 100644 index 00000000..ea8a97be --- /dev/null +++ b/Tusker/SavedHashtagsManager.swift @@ -0,0 +1,65 @@ +// +// SavedHashtagsManager.swift +// Tusker +// +// Created by Shadowfacts on 12/19/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import Foundation +import Pachyderm + +class SavedHashtagsManager: Codable { + private(set) static var shared: SavedHashtagsManager = load() + + private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + private static var archiveURL = SavedHashtagsManager.documentsDirectory.appendingPathComponent("saved_hashtags").appendingPathExtension("plist") + + static func save() { + DispatchQueue.global(qos: .userInitiated).async { + let encoder = PropertyListEncoder() + let data = try? encoder.encode(shared) + try? data?.write(to: archiveURL, options: .noFileProtection) + } + } + + static func load() -> SavedHashtagsManager { + let decoder = PropertyListDecoder() + if let data = try? Data(contentsOf: archiveURL), + let savedHashtagsManager = try? decoder.decode(Self.self, from: data) { + return savedHashtagsManager + } + return SavedHashtagsManager() + } + + private init() {} + + private var savedHashtags: [Hashtag] = [] + var sorted: [Hashtag] { + return savedHashtags.sorted(by: { $0.name > $1.name }) + } + + func isSaved(_ hashtag: Hashtag) -> Bool { + return savedHashtags.contains(hashtag) + } + + func add(_ hashtag: Hashtag) { + if isSaved(hashtag) { + return + } + savedHashtags.append(hashtag) + SavedHashtagsManager.save() + NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) + } + + func remove(_ hashtag: Hashtag) { + guard isSaved(hashtag) else { return } + savedHashtags.removeAll(where: { $0.name == hashtag.name }) + SavedHashtagsManager.save() + NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) + } +} + +extension Foundation.Notification.Name { + static let savedHashtagsChanged = Notification.Name("savedHashtagsChanged") +} diff --git a/Tusker/Screens/Explore/AddSavedHashtagViewController.swift b/Tusker/Screens/Explore/AddSavedHashtagViewController.swift new file mode 100644 index 00000000..f45eb025 --- /dev/null +++ b/Tusker/Screens/Explore/AddSavedHashtagViewController.swift @@ -0,0 +1,58 @@ +// +// AddSavedHashtagViewController.swift +// Tusker +// +// Created by Shadowfacts on 12/19/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class AddSavedHashtagViewController: SearchResultsViewController { + + var searchController: UISearchController! + + override func viewDidLoad() { + super.viewDidLoad() + + delegate = self + onlySections = [.hashtags] + + searchController = UISearchController(searchResultsController: nil) + searchController.obscuresBackgroundDuringPresentation = false + searchController.hidesNavigationBarDuringPresentation = false + searchController.searchBar.autocapitalizationType = .none + searchController.searchBar.placeholder = NSLocalizedString("Search for hashtags to save", comment: "add saved hashtag search field placeholder") + searchController.searchBar.delegate = self + + definesPresentationContext = true + + navigationItem.searchController = searchController + navigationItem.hidesSearchBarWhenScrolling = false + + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed)) + } + + override func performSearch(query: String?) { + if let query = query, !query.starts(with: "#") { + super.performSearch(query: "#\(query)") + } else { + super.performSearch(query: query) + } + } + + // MARK: - Interaction + + @objc func cancelButtonPressed() { + dismiss(animated: true) + } + +} + +extension AddSavedHashtagViewController: SearchResultsViewControllerDelegate { + func selectedSearchResult(hashtag: Hashtag) { + SavedHashtagsManager.shared.add(hashtag) + dismiss(animated: true) + } +} diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 1e850196..6a5d8607 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -43,7 +43,7 @@ class ExploreViewController: EnhancedTableViewController { cell.accessoryType = .disclosureIndicator case let .list(list): - cell.imageView!.image = nil + cell.imageView!.image = UIImage(systemName: "list.bullet") cell.textLabel!.text = list.title cell.accessoryType = .disclosureIndicator @@ -51,6 +51,16 @@ class ExploreViewController: EnhancedTableViewController { cell.imageView!.image = UIImage(systemName: "plus") cell.textLabel!.text = NSLocalizedString("New List...", comment: "new list nav item title") cell.accessoryType = .none + + case let .savedHashtag(hashtag): + cell.imageView!.image = UIImage(systemName: "number") + cell.textLabel!.text = hashtag.name + cell.accessoryType = .disclosureIndicator + + case .addSavedHashtag: + cell.imageView!.image = UIImage(systemName: "plus") + cell.textLabel!.text = NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title") + cell.accessoryType = .none } return cell @@ -58,9 +68,10 @@ class ExploreViewController: EnhancedTableViewController { dataSource.exploreController = self var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.bookmarks, .lists]) + snapshot.appendSections([.bookmarks, .lists, .savedHashtags]) snapshot.appendItems([.bookmarks], toSection: .bookmarks) snapshot.appendItems([.addList], toSection: .lists) + snapshot.appendItems(SavedHashtagsManager.shared.sorted.map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags) // the initial, static items should not be displayed with an animation UIView.performWithoutAnimation { dataSource.apply(snapshot) @@ -77,6 +88,8 @@ class ExploreViewController: EnhancedTableViewController { navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false + NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) + reloadLists() } @@ -88,16 +101,48 @@ class ExploreViewController: EnhancedTableViewController { } var snapshot = self.dataSource.snapshot() - snapshot.deleteSections([.lists]) - snapshot.appendSections([.lists]) + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .lists)) snapshot.appendItems(lists.map { .list($0) } + [.addList], toSection: .lists) - + DispatchQueue.main.async { self.dataSource.apply(snapshot) } } } + @objc func savedHashtagsChanged() { + var snapshot = dataSource.snapshot() + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) + snapshot.appendItems(SavedHashtagsManager.shared.sorted.map { .savedHashtag($0) } + [.addSavedHashtag], toSection: .savedHashtags) + dataSource.apply(snapshot) + } + + func deleteList(_ list: List) { + let title = String(format: NSLocalizedString("Are you sure want to delete the '%@' list?", comment: "delete list alert title"), list.title) + let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: nil)) + alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in + + let request = List.delete(list) + MastodonController.client.run(request) { (response) in + guard case .success(_, _) = response else { + fatalError() + } + + var snapshot = self.dataSource.snapshot() + snapshot.deleteItems([.list(list)]) + DispatchQueue.main.async { + self.dataSource.apply(snapshot) + } + } + })) + present(alert, animated: true) + } + + func removeSavedHashtag(_ hashtag: Hashtag) { + SavedHashtagsManager.shared.remove(hashtag) + } + // MARK: - Table view delegate override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { @@ -133,6 +178,14 @@ class ExploreViewController: EnhancedTableViewController { } })) present(alert, animated: true) + + case let .savedHashtag(hashtag): + show(HashtagTimelineViewController(for: hashtag), sender: nil) + + case .addSavedHashtag: + tableView.selectRow(at: nil, animated: true, scrollPosition: .none) + let navController = UINavigationController(rootViewController: AddSavedHashtagViewController()) + present(navController, animated: true) } } @@ -146,12 +199,15 @@ extension ExploreViewController { enum Section: CaseIterable { case bookmarks case lists + case savedHashtags } enum Item: Hashable { case bookmarks case list(List) case addList - + case savedHashtag(Hashtag) + case addSavedHashtag + static func == (lhs: ExploreViewController.Item, rhs: ExploreViewController.Item) -> Bool { switch (lhs, rhs) { case (.bookmarks, .bookmarks): @@ -160,6 +216,10 @@ extension ExploreViewController { return a.id == b.id case (.addList, .addList): return true + case let (.savedHashtag(a), .savedHashtag(b)): + return a == b + case (.addSavedHashtag, .addSavedHashtag): + return true default: return false } @@ -173,6 +233,11 @@ extension ExploreViewController { hasher.combine(list.id) case .addList: hasher.combine("addList") + case let .savedHashtag(hashtag): + hasher.combine("savedHashtag") + hasher.combine(hashtag.name) + case .addSavedHashtag: + hasher.combine("addSavedHashtag") } } } @@ -185,43 +250,39 @@ extension ExploreViewController { switch section { case 1: return NSLocalizedString("Lists", comment: "explore lists section title") + case 2: + return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title") default: return nil } } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { - if case .list(_) = itemIdentifier(for: indexPath) { + switch itemIdentifier(for: indexPath) { + case .list(_): return true + case .savedHashtag(_): + return true + default: + return false } - return false } override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { guard editingStyle == .delete, - case let .list(list) = itemIdentifier(for: indexPath) else { - return + let exploreController = exploreController else { + return } - let title = String(format: NSLocalizedString("Are you sure want to delete the '%@' list?", comment: "delete list alert title"), list.title) - let alert = UIAlertController(title: title, message: nil, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "delete list alert cancel button"), style: .cancel, handler: nil)) - alert.addAction(UIAlertAction(title: NSLocalizedString("Delete List", comment: "delete list alert confirm button"), style: .destructive, handler: { (_) in - - let request = List.delete(list) - MastodonController.client.run(request) { (response) in - guard case .success(_, _) = response else { - fatalError() - } - - var snapshot = self.snapshot() - snapshot.deleteItems([.list(list)]) - DispatchQueue.main.async { - self.apply(snapshot) - } - } - })) - self.exploreController?.present(alert, animated: true) + switch itemIdentifier(for: indexPath) { + case let .list(list): + exploreController.deleteList(list) + case let .savedHashtag(hashtag): + exploreController.removeSavedHashtag(hashtag) + default: + return + } + } } diff --git a/Tusker/Screens/Hashtag Timeline/HashtagTimelineViewController.swift b/Tusker/Screens/Hashtag Timeline/HashtagTimelineViewController.swift new file mode 100644 index 00000000..0313a11e --- /dev/null +++ b/Tusker/Screens/Hashtag Timeline/HashtagTimelineViewController.swift @@ -0,0 +1,58 @@ +// +// HashtagTimelineViewController.swift +// Tusker +// +// Created by Shadowfacts on 12/19/19. +// Copyright © 2019 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class HashtagTimelineViewController: TimelineTableViewController { + + let hashtag: Hashtag + + var toggleSaveButton: UIBarButtonItem! + var toggleSaveButtonTitle: String { + if SavedHashtagsManager.shared.isSaved(hashtag) { + return NSLocalizedString("Unsave", comment: "unsave hashtag button") + } else { + return NSLocalizedString("Save", comment: "save hashtag button") + } + } + + init(for hashtag: Hashtag) { + self.hashtag = hashtag + + super.init(for: .tag(hashtag: hashtag.name)) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + toggleSaveButton = UIBarButtonItem(title: toggleSaveButtonTitle, style: .plain, target: self, action: #selector(toggleSaveButtonPressed)) + navigationItem.rightBarButtonItem = toggleSaveButton + + NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) + } + + @objc func savedHashtagsChanged() { + toggleSaveButton.title = toggleSaveButtonTitle + } + + // MARK: - Interaction + + @objc func toggleSaveButtonPressed() { + if SavedHashtagsManager.shared.isSaved(hashtag) { + SavedHashtagsManager.shared.remove(hashtag) + } else { + SavedHashtagsManager.shared.add(hashtag) + } + } + +} diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 87931b7a..d8aacef1 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -76,7 +76,7 @@ extension TuskerNavigationDelegate where Self: UIViewController { } func selected(tag: Hashtag) { - show(TimelineTableViewController(for: .tag(hashtag: tag.name)), sender: self) + show(HashtagTimelineViewController(for: tag), sender: self) } func selected(url: URL) { diff --git a/Tusker/Views/ContentLabel.swift b/Tusker/Views/ContentLabel.swift index d168326f..1cdced23 100644 --- a/Tusker/Views/ContentLabel.swift +++ b/Tusker/Views/ContentLabel.swift @@ -181,7 +181,7 @@ class ContentLabel: LinkLabel { if let mention = getMention(for: url, text: text) { return ProfileTableViewController(accountID: mention.id) } else if let tag = getHashtag(for: url, text: text) { - return TimelineTableViewController(for: .tag(hashtag: tag.name)) + return HashtagTimelineViewController(for: tag) } else { return SFSafariViewController(url: url) }