Compare commits

..

6 Commits

Author SHA1 Message Date
Shadowfacts f1a39c2faa Add follow/unfollow hashtag actions 2022-11-29 23:14:36 -05:00
Shadowfacts ab8e498cee Refactor menu actions to allow presenting from menu bar items 2022-11-29 23:14:36 -05:00
Shadowfacts c6da754875 Indicate when a followed hashtag caused a post to appear in the home timeline 2022-11-29 23:14:36 -05:00
Shadowfacts 97d5b955a0 Store followed hashtags
The followed hashtags may not load until after the timeline request
completes, and we want to be able to show the hashtag indicator (or at
least make a best effort attempt) immediately.
2022-11-29 23:14:36 -05:00
Shadowfacts 80f9800fd6 Completely replace all items when jumping to present 2022-11-29 20:53:00 -05:00
Shadowfacts 0485400c1f Tweak how InstanceFeatures is updated 2022-11-29 20:52:39 -05:00
33 changed files with 435 additions and 141 deletions

View File

@ -261,6 +261,10 @@ public class Client {
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct])) return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
} }
public static func getFollowedHashtags() -> Request<[Hashtag]> {
return Request(method: .get, path: "/api/v1/followed_tags")
}
// MARK: - Lists // MARK: - Lists
public static func getLists() -> Request<[List]> { public static func getLists() -> Request<[List]> {
return Request<[List]>(method: .get, path: "/api/v1/lists") return Request<[List]>(method: .get, path: "/api/v1/lists")

View File

@ -15,11 +15,14 @@ public class Hashtag: Codable {
public let url: WebURL public let url: WebURL
/// Only present when returned from the trending hashtags endpoint /// Only present when returned from the trending hashtags endpoint
public let history: [History]? public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in
public let following: Bool?
public init(name: String, url: URL) { public init(name: String, url: URL) {
self.name = name self.name = name
self.url = WebURL(url)! self.url = WebURL(url)!
self.history = nil self.history = nil
self.following = nil
} }
public required init(from decoder: Decoder) throws { public required init(from decoder: Decoder) throws {
@ -28,6 +31,7 @@ public class Hashtag: Codable {
// pixelfed (possibly others) don't fully escape special characters in the hashtag url // pixelfed (possibly others) don't fully escape special characters in the hashtag url
self.url = try container.decode(WebURL.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
self.history = try container.decodeIfPresent([History].self, forKey: .history) self.history = try container.decodeIfPresent([History].self, forKey: .history)
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
} }
public func encode(to encoder: Encoder) throws { public func encode(to encoder: Encoder) throws {
@ -35,12 +39,22 @@ public class Hashtag: Codable {
try container.encode(name, forKey: .name) try container.encode(name, forKey: .name)
try container.encode(url, forKey: .url) try container.encode(url, forKey: .url)
try container.encodeIfPresent(history, forKey: .history) try container.encodeIfPresent(history, forKey: .history)
try container.encodeIfPresent(following, forKey: .following)
}
public static func follow(name: String) -> Request<Hashtag> {
return Request(method: .post, path: "/api/v1/tags/\(name)/follow")
}
public static func unfollow(name: String) -> Request<Hashtag> {
return Request(method: .post, path: "/api/v1/tags/\(name)/unfollow")
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
case url case url
case history case history
case following
} }
} }

View File

@ -50,6 +50,8 @@
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; }; D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; }; D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; }; D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; }; D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
@ -292,7 +294,7 @@
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; }; D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; }; D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; }; D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; }; D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; }; D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; }; D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; };
D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */; }; D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */; };
@ -416,6 +418,8 @@
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; }; D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; }; D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; };
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; }; D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
@ -666,7 +670,7 @@
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; }; D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; }; D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; }; D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; }; D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; }; D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInTusker.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInTusker.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D6E343AA265AAD6B00C4AA01 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; }; D6E343AA265AAD6B00C4AA01 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
@ -881,6 +885,7 @@
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */, D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
D6B9366C2828444F00237D0E /* SavedInstance.swift */, D6B9366C2828444F00237D0E /* SavedInstance.swift */,
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
); );
@ -1418,7 +1423,7 @@
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */, D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */, D6DFC69F242C4CCC00ACC392 /* Weak.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */, D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */, D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */, D6AEBB3F2321640F00E5038B /* Activities */,
@ -1526,6 +1531,7 @@
D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */,
D6F6A551291F098700F496A8 /* RenameListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */,
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
); );
path = API; path = API;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1824,6 +1830,7 @@
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */, D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */, D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */, D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */, D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */, D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
@ -1994,6 +2001,7 @@
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */, 04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
@ -2006,7 +2014,7 @@
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */, D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */, D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */, D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */, D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */, D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */, D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */, D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,

View File

@ -84,6 +84,14 @@ struct InstanceFeatures {
} }
} }
var canFollowHashtags: Bool {
if case .mastodon(_, .some(let version)) = instanceType {
return version >= Version(4, 0, 0)
} else {
return false
}
}
mutating func update(instance: Instance, nodeInfo: NodeInfo?) { mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased() let ver = instance.version.lowercased()
if ver.contains("glitch") { if ver.contains("glitch") {

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import Combine
class MastodonController: ObservableObject { class MastodonController: ObservableObject {
@ -47,7 +48,10 @@ class MastodonController: ObservableObject {
@Published private(set) var nodeInfo: NodeInfo! @Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var instanceFeatures = InstanceFeatures() @Published private(set) var instanceFeatures = InstanceFeatures()
@Published private(set) var lists: [List] = [] @Published private(set) var lists: [List] = []
private(set) var customEmojis: [Emoji]? @Published private(set) var customEmojis: [Emoji]?
@Published private(set) var followedHashtags: [FollowedHashtag] = []
private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]() private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask? private var ownInstanceRequest: URLSessionTask?
@ -61,6 +65,29 @@ class MastodonController: ObservableObject {
self.accountInfo = nil self.accountInfo = nil
self.client = Client(baseURL: instanceURL, session: .appDefault) self.client = Client(baseURL: instanceURL, session: .appDefault)
self.transient = transient self.transient = transient
$instance
.combineLatest($nodeInfo)
.compactMap { (instance, nodeInfo) in
if let instance {
return (instance, nodeInfo)
} else {
return nil
}
}
.sink { [unowned self] (instance, nodeInfo) in
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
$instanceFeatures
.filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty }
.sink { [unowned self] _ in
Task {
await self.loadFollowedHashtags()
}
}
.store(in: &cancellables)
} }
@discardableResult @discardableResult
@ -230,7 +257,6 @@ class MastodonController: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self.ownInstanceRequest = nil self.ownInstanceRequest = nil
self.instance = instance self.instance = instance
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
for completion in self.pendingOwnInstanceRequestCallbacks { for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.success(instance)) completion(.success(instance))
@ -248,9 +274,6 @@ class MastodonController: ObservableObject {
case let .success(nodeInfo, _): case let .success(nodeInfo, _):
DispatchQueue.main.async { DispatchQueue.main.async {
self.nodeInfo = nodeInfo self.nodeInfo = nodeInfo
if let instance = self.instance {
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
} }
} }
} }
@ -308,6 +331,25 @@ class MastodonController: ObservableObject {
self.lists = new self.lists = new
} }
@MainActor
private func loadFollowedHashtags() async {
updateFollowedHashtags()
let req = Client.getFollowedHashtags()
if let (hashtags, _) = try? await run(req) {
self.persistentContainer.updateFollowedHashtags(hashtags) {
if case .success(let hashtags) = $0 {
self.followedHashtags = hashtags
}
}
}
}
@MainActor
func updateFollowedHashtags() {
followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
}
} }
private struct ListComparator: SortComparator { private struct ListComparator: SortComparator {

View File

@ -0,0 +1,67 @@
//
// ToggleFollowHashtagService.swift
// Tusker
//
// Created by Shadowfacts on 11/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class ToggleFollowHashtagService {
private let hashtag: Hashtag
private let mastodonController: MastodonController
private let presenter: any TuskerNavigationDelegate
init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) {
self.hashtag = hashtag
self.mastodonController = presenter.apiController
self.presenter = presenter
}
func toggleFollow() async {
let context = mastodonController.persistentContainer.viewContext
var config: ToastConfiguration
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtag.name }) {
do {
let req = Hashtag.unfollow(name: hashtag.name)
_ = try await mastodonController.run(req)
context.delete(existing)
mastodonController.updateFollowedHashtags()
config = ToastConfiguration(title: "Unfollowed Hashtag")
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2
} catch {
config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.toggleFollow()
}
}
} else {
do {
let req = Hashtag.follow(name: hashtag.name)
let (hashtag, _) = try await mastodonController.run(req)
_ = FollowedHashtag(hashtag: hashtag, context: context)
mastodonController.updateFollowedHashtags()
config = ToastConfiguration(title: "Followed Hashtag")
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2
} catch {
config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.toggleFollow()
}
}
}
presenter.showToast(configuration: config, animated: true)
mastodonController.persistentContainer.save(context: context)
}
}

View File

@ -0,0 +1,38 @@
//
// FollowedHashtag.swift
// Tusker
//
// Created by Shadowfacts on 11/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import WebURLFoundationExtras
@objc(FollowedHashtag)
public final class FollowedHashtag: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<FollowedHashtag> {
return NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
}
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<FollowedHashtag> {
let req = NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
req.predicate = NSPredicate(format: "name = %@", name)
return req
}
@NSManaged public var name: String
@NSManaged public var url: URL
}
extension FollowedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
self.init(context: context)
self.name = hashtag.name
self.url = URL(hashtag.url)!
}
}

View File

@ -267,6 +267,38 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) {
viewContext.perform {
do {
var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest())
let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID)
if !toDelete.isEmpty {
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
}
for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) {
let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext)
all.append(mo)
}
self.save(context: self.viewContext)
completion(.success(all))
} catch {
completion(.failure(error))
}
}
}
func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool {
do {
let req = FollowedHashtag.fetchRequest(name: name)
return try viewContext.count(for: req) > 0
} catch {
return false
}
}
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
let changes = hasChangedSavedHashtagsOrInstances(notification) let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags { if changes.hashtags {

View File

@ -28,6 +28,10 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<entity name="FollowedHashtag" representedClassName="FollowedHashtag" syncable="YES">
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
</entity>
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES"> <entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>

View File

@ -125,7 +125,7 @@ private func createReblogAction(status: StatusMO, container: StatusSwipeActionCo
private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction { private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in
container.navigationDelegate.showMoreOptions(forStatus: status.id, sourceView: container) container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
completion(true) completion(true)
} }
// bold to more closesly match other action symbols // bold to more closesly match other action symbols

View File

@ -88,7 +88,7 @@ extension AccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController) ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in } actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell)) UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
} }
} }

View File

@ -174,7 +174,7 @@ extension ProfileDirectoryViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController) return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
let actions = self.actionsForProfile(accountID: account.id, sourceView: self.collectionView.cellForItem(at: indexPath)) let actions = self.actionsForProfile(accountID: account.id, source: .view(self.collectionView.cellForItem(at: indexPath)))
return UIMenu(children: actions) return UIMenu(children: actions)
} }

View File

@ -98,7 +98,7 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath))) UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath))))
} }
} }
} }

View File

@ -270,7 +270,7 @@ extension SearchViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath))) UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath))))
} }
case let .link(card): case let .link(card):

View File

@ -206,7 +206,7 @@ extension StatusActionAccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController) ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in } actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell)) UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
} }
} }
} }

View File

@ -14,18 +14,15 @@ class HashtagTimelineViewController: TimelineViewController {
let hashtag: Hashtag let hashtag: Hashtag
var toggleSaveButton: UIBarButtonItem! var toggleSaveButton: UIBarButtonItem!
var toggleSaveButtonTitle: String {
if isHashtagSaved {
return NSLocalizedString("Unsave", comment: "unsave hashtag button")
} else {
return NSLocalizedString("Save", comment: "save hashtag button")
}
}
private var isHashtagSaved: Bool { private var isHashtagSaved: Bool {
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name)) mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
} }
private var isHashtagFollowed: Bool {
mastodonController.followedHashtags.contains(where: { $0.name == hashtag.name })
}
init(for hashtag: Hashtag, mastodonController: MastodonController) { init(for hashtag: Hashtag, mastodonController: MastodonController) {
self.hashtag = hashtag self.hashtag = hashtag
@ -39,19 +36,16 @@ class HashtagTimelineViewController: TimelineViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
toggleSaveButton = UIBarButtonItem(title: toggleSaveButtonTitle, style: .plain, target: self, action: #selector(toggleSaveButtonPressed)) let menu = UIMenu(children: [
navigationItem.rightBarButtonItem = toggleSaveButton // uncached so that the saved/followed updates every time
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
})
])
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
} }
@objc func savedHashtagsChanged() { private func toggleSave() {
toggleSaveButton.title = toggleSaveButtonTitle
}
// MARK: - Interaction
@objc func toggleSaveButtonPressed() {
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(existing) context.delete(existing)
@ -61,4 +55,10 @@ class HashtagTimelineViewController: TimelineViewController {
mastodonController.persistentContainer.save(context: context) mastodonController.persistentContainer.save(context: context)
} }
private func toggleFollow() {
Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow()
}
}
} }

View File

@ -101,6 +101,11 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) { func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
cell.delegate = self cell.delegate = self
if case .home = timeline {
cell.showFollowedHashtags = true
} else {
cell.showFollowedHashtags = false
}
cell.updateUI(statusID: id, state: state) cell.updateUI(statusID: id, state: state)
} }
@ -343,21 +348,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
private func insertPresentItemsIfNecessary(_ presentItems: [String]) { private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot() let snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else { guard snapshot.indexOfSection(.statuses) != nil else {
return return
} }
let currentItems = snapshot.itemIdentifiers(inSection: .statuses) let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, state: _) = currentItems.first, if case .status(id: let firstID, state: _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user // if there's no overlap between presentItems and the existing items in the data source, prompt the user
!presentItems.contains(firstID) { !presentItems.contains(firstID) {
// remove any existing gap, if there is one // create a new snapshot to reset the timeline to the "present" state
if let index = currentItems.lastIndex(of: .gap) { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.deleteItems(Array(currentItems[index...])) snapshot.appendSections([.statuses])
} snapshot.appendItems(presentItems.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
var config = ToastConfiguration(title: "Jump to present") var config = ToastConfiguration(title: "Jump to present")
config.edge = .top config.edge = .top
@ -366,12 +369,34 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.action = { [unowned self] toast in config.action = { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
let origSnapshot = self.dataSource.snapshot()
let origItemAtTop: (Item, CGFloat)?
if let statusesSection = origSnapshot.indexOfSection(.statuses),
let indexPath = self.collectionView.indexPathsForVisibleItems.sorted().first(where: { $0.section == statusesSection }),
let cell = self.collectionView.cellForItem(at: indexPath),
let item = self.dataSource.itemIdentifier(for: indexPath) {
origItemAtTop = (item, cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top)
} else {
origItemAtTop = nil
}
self.dataSource.apply(snapshot, animatingDifferences: true) { self.dataSource.apply(snapshot, animatingDifferences: true) {
// TODO: we can't set prevScrollOffsetBeforeScrollToTop here to allow undoing the scroll-to-top
// because that would involve scrolling through unmeasured-cell which fucks up the content offset values.
// we probably need a data-source aware implementation of scrollToTop which uses item & offset w/in item
// to track the restore position
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true) self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
var config = ToastConfiguration(title: "Go back")
config.edge = .top
config.systemImageName = "arrow.down"
config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
// todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff
if let (item, offset) = origItemAtTop {
self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item)
} else {
self.dataSource.apply(origSnapshot, animatingDifferences: false)
}
}
self.showToast(configuration: config, animated: true)
} }
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
@ -380,32 +405,35 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// NOTE: this only works when items are being inserted ABOVE the item to maintain // NOTE: this only works when items are being inserted ABOVE the item to maintain
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) { private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0 var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
if let indexPath = dataSource.indexPath(for: itemToMaintain), if let indexPath = dataSource.indexPath(for: itemToMaintain),
let cell = collectionView.cellForItem(at: indexPath) { let cell = collectionView.cellForItem(at: indexPath) {
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area // subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
} }
applySnapshot(snapshot, maintainingScreenPosition: firstItemAfterOriginalGapOffsetFromTop, ofItem: itemToMaintain)
}
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
dataSource.apply(snapshot, animatingDifferences: false) { dataSource.apply(snapshot, animatingDifferences: false) {
if let indexPathOfItemAfterOriginalGap = self.dataSource.indexPath(for: itemToMaintain) { if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
// scroll up until we've accumulated enough MEASURED height that we can put the // scroll up until we've accumulated enough MEASURED height that we can put the
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by // firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area // firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemAfterOriginalGap var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0 var amountScrolledUp: CGFloat = 0
while true { while true {
if cur.row <= 0 { if cur.row <= 0 {
break break
} }
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap), if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop { cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
break break
} }
cur = IndexPath(row: cur.row - 1, section: cur.section) cur = IndexPath(row: cur.row - 1, section: cur.section)
@ -415,7 +443,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
amountScrolledUp += attrs.size.height amountScrolledUp += attrs.size.height
} }
self.collectionView.contentOffset.y += amountScrolledUp self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop self.collectionView.contentOffset.y -= offsetFromTop
} }
snapshotView.removeFromSuperview() snapshotView.removeFromSuperview()

View File

@ -39,15 +39,15 @@ extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController } private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] { func actionsForProfile(accountID: String, source: PopoverSource) -> [UIMenuElement] {
guard let mastodonController = mastodonController, guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] } let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
var shareSection = [ var shareSection = [
openInSafariAction(url: account.url), openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forAccount: accountID, source: source)
}) })
] ]
@ -95,24 +95,27 @@ extension MenuActionProvider {
] ]
} }
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] { func actionsForURL(_ url: URL, source: PopoverSource) -> [UIAction] {
return [ return [
openInSafariAction(url: url), openInSafariAction(url: url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
}) })
] ]
} }
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] { func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
let actionsSection: [UIMenuElement] var actionsSection: [UIMenuElement] = []
if let mastodonController = mastodonController, if let mastodonController = mastodonController,
mastodonController.loggedIn { mastodonController.loggedIn {
let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
actionsSection = [ actionsSection = [
createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
if let existing = existing { if let existing = existing {
context.delete(existing) context.delete(existing)
} else { } else {
@ -121,13 +124,21 @@ extension MenuActionProvider {
mastodonController.persistentContainer.save(context: context) mastodonController.persistentContainer.save(context: context)
}) })
] ]
} else { if mastodonController.instanceFeatures.canFollowHashtags {
actionsSection = [] let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
let subtitle = "Posts tagged with followed hashtags appear in your Home timeline"
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus")
actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow()
}
})
}
} }
let shareSection: [UIMenuElement] let shareSection: [UIMenuElement]
if let url = URL(hashtag.url) { if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, sourceView: sourceView) shareSection = actionsForURL(url, source: source)
} else { } else {
shareSection = [] shareSection = []
} }
@ -138,16 +149,16 @@ extension MenuActionProvider {
] ]
} }
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeStatusButtonActions: Bool = true) -> [UIMenuElement] { func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] } guard let mastodonController = mastodonController else { return [] }
guard let accountID = mastodonController.accountInfo?.id else { guard let accountID = mastodonController.accountInfo?.id else {
// user is logged out // user is logged out
return [ return [
openInSafariAction(url: status.url!), openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
}) })
] ]
} }
@ -271,9 +282,9 @@ extension MenuActionProvider {
} else { } else {
Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)") Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)")
} }
shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return } guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView) self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
})) }))
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID)) addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))

View File

@ -159,21 +159,21 @@ extension TuskerNavigationDelegate {
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil) return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
} }
func showMoreOptions(forStatus statusID: String, sourceView: UIView?) { func showMoreOptions(forStatus statusID: String, source: PopoverSource) {
let vc = moreOptions(forStatus: statusID) let vc = moreOptions(forStatus: statusID)
vc.popoverPresentationController?.sourceView = sourceView source.apply(to: vc)
present(vc, animated: true) present(vc, animated: true)
} }
func showMoreOptions(forURL url: URL, sourceView: UIView?) { func showMoreOptions(forURL url: URL, source: PopoverSource) {
let vc = moreOptions(forURL: url) let vc = moreOptions(forURL: url)
vc.popoverPresentationController?.sourceView = sourceView source.apply(to: vc)
present(vc, animated: true) present(vc, animated: true)
} }
func showMoreOptions(forAccount accountID: String, sourceView: UIView?) { func showMoreOptions(forAccount accountID: String, source: PopoverSource) {
let vc = moreOptions(forAccount: accountID) let vc = moreOptions(forAccount: accountID)
vc.popoverPresentationController?.sourceView = sourceView source.apply(to: vc)
present(vc, animated: true) present(vc, animated: true)
} }
@ -188,3 +188,30 @@ extension TuskerNavigationDelegate {
} }
} }
enum PopoverSource {
case none
case view(WeakHolder<UIView>)
case barButtonItem(WeakHolder<UIBarButtonItem>)
func apply(to viewController: UIViewController) {
if let popoverPresentationController = viewController.popoverPresentationController {
switch self {
case .none:
break
case .view(let view):
popoverPresentationController.sourceView = view.object
case .barButtonItem(let item):
popoverPresentationController.barButtonItem = item.object
}
}
}
static func view(_ view: UIView?) -> Self {
.view(WeakHolder(view))
}
static func barButtonItem(_ item: UIBarButtonItem?) -> Self {
.barButtonItem(WeakHolder(item))
}
}

View File

@ -109,7 +109,7 @@ extension AccountTableViewCell: MenuPreviewProvider {
guard let mastodonController = mastodonController else { return nil } guard let mastodonController = mastodonController else { return nil }
return ( return (
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) }, content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) },
actions: { self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? [] } actions: { self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [] }
) )
} }
} }

View File

@ -321,11 +321,11 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
let text = (self.text as NSString).substring(with: range) let text = (self.text as NSString).substring(with: range)
let actions: [UIMenuElement] let actions: [UIMenuElement]
if let mention = self.getMention(for: link, text: text) { if let mention = self.getMention(for: link, text: text) {
actions = self.actionsForProfile(accountID: mention.id, sourceView: self) actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
} else if let tag = self.getHashtag(for: link, text: text) { } else if let tag = self.getHashtag(for: link, text: text) {
actions = self.actionsForHashtag(tag, sourceView: self) actions = self.actionsForHashtag(tag, source: .view(self))
} else { } else {
actions = self.actionsForURL(link, sourceView: self) actions = self.actionsForURL(link, source: .view(self))
} }
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
} }

View File

@ -214,7 +214,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
} }
}, actions: { }, actions: {
if accountIDs.count == 1 { if accountIDs.count == 1 {
return self.delegate?.actionsForProfile(accountID: accountIDs.first!, sourceView: self) ?? [] return self.delegate?.actionsForProfile(accountID: accountIDs.first!, source: .view(self)) ?? []
} else { } else {
return [] return []
} }

View File

@ -114,7 +114,7 @@ extension PollFinishedTableViewCell: MenuPreviewProvider {
return (content: { return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown) delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: { }, actions: {
delegate.actionsForStatus(status, sourceView: self) delegate.actionsForStatus(status, source: .view(self))
}) })
} }
} }

View File

@ -108,7 +108,7 @@ extension StatusUpdatedNotificationTableViewCell: MenuPreviewProvider {
return (content: { return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown) delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: { }, actions: {
delegate.actionsForStatus(status, sourceView: self) delegate.actionsForStatus(status, source: .view(self))
}) })
} }
} }

View File

@ -122,7 +122,7 @@ class ProfileHeaderView: UIView {
updateImages(account: account) updateImages(account: account)
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, sourceView: moreButton) ?? []) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton)) ?? [])
noteTextView.navigationDelegate = delegate noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)

View File

@ -209,7 +209,7 @@ class BaseStatusTableViewCell: UITableViewCell {
// keep menu in sync with changed states e.g. bookmarked, muted // keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it // do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? []) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
pollView.isHidden = status.poll == nil pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController pollView.mastodonController = mastodonController
@ -408,7 +408,7 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
@IBAction func morePressed() { @IBAction func morePressed() {
delegate?.showMoreOptions(forStatus: statusID, sourceView: moreButton) delegate?.showMoreOptions(forStatus: statusID, source: .view(moreButton))
} }
@objc func accountPressed() { @objc func accountPressed() {

View File

@ -135,7 +135,7 @@ extension ConversationMainStatusTableViewCell: UIContextMenuInteractionDelegate
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? []) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [])
} }
} }
} }

View File

@ -230,7 +230,7 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
return SFSafariViewController(url: URL(card.url)!) return SFSafariViewController(url: URL(card.url)!)
} actionProvider: { (_) in } actionProvider: { (_) in
let actions = self.actionProvider?.actionsForURL(URL(card.url)!, sourceView: self) ?? [] let actions = self.actionProvider?.actionsForURL(URL(card.url)!, source: .view(self)) ?? []
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
} }
} }

View File

@ -195,7 +195,7 @@ extension StatusCollectionViewCell {
// keep menu in sync with changed states e.g. bookmarked, muted // keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it // do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? []) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
contentContainer.pollView.isHidden = status.poll == nil contentContainer.pollView.isHidden = status.poll == nil
contentContainer.pollView.mastodonController = mastodonController contentContainer.pollView.mastodonController = mastodonController
@ -257,7 +257,7 @@ extension StatusCollectionViewCell {
return UIContextMenuConfiguration() { return UIContextMenuConfiguration() {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { _ in } actionProvider: { _ in
return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: sourceView) ?? []) return UIMenu(children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(sourceView)) ?? [])
} }
} }
} }

View File

@ -10,23 +10,26 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
private let reblogIcon = UIImage(systemName: "repeat")
private let hashtagIcon = UIImage(systemName: "number")
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell { class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0) static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
// MARK: Subviews // MARK: Subviews
private lazy var rebloggerLabel = EmojiLabel().configure { private lazy var timelineReasonLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel $0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body) $0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true $0.adjustsFontForContentSizeCategory = true
} }
private let reblogIcon = UIImageView(image: UIImage(systemName: "repeat")).configure { private let timelineReasonIcon = UIImageView(image: reblogIcon).configure {
$0.tintColor = .secondaryLabel $0.tintColor = .secondaryLabel
} }
private lazy var reblogHStack = UIStackView(arrangedSubviews: [ private lazy var timelineReasonHStack = UIStackView(arrangedSubviews: [
reblogIcon, timelineReasonIcon,
rebloggerLabel, timelineReasonLabel,
]).configure { ]).configure {
$0.axis = .horizontal $0.axis = .horizontal
$0.spacing = 8 $0.spacing = 8
@ -258,6 +261,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
true true
} }
var showPinned: Bool = false var showPinned: Bool = false
var showFollowedHashtags: Bool = false
// alas these need to be internal so they're accessible from the protocol extensions // alas these need to be internal so they're accessible from the protocol extensions
var statusID: String! var statusID: String!
@ -275,12 +279,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
for subview in [reblogHStack, mainContainer, actionsContainer] { for subview in [timelineReasonHStack, mainContainer, actionsContainer] {
subview.translatesAutoresizingMaskIntoConstraints = false subview.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(subview) contentView.addSubview(subview)
} }
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: reblogHStack.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4)
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8) mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4) mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6) mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
@ -291,9 +295,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced // why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
reblogHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), timelineReasonHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
rebloggerLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor), timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
reblogHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16), timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16),
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
@ -427,23 +431,35 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
self.statusState = state self.statusState = state
var hideTimelineReason = true
if let rebloggedStatus = status.reblog { if let rebloggedStatus = status.reblog {
reblogStatusID = statusID reblogStatusID = statusID
rebloggerID = status.account.id rebloggerID = status.account.id
reblogHStack.isHidden = false
mainContainerTopToReblogLabelConstraint.isActive = true
mainContainerTopToSelfConstraint.isActive = false
updateRebloggerLabel(reblogger: status.account)
hideTimelineReason = false
timelineReasonIcon.image = reblogIcon
updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus status = rebloggedStatus
} else { } else {
reblogStatusID = nil reblogStatusID = nil
rebloggerID = nil rebloggerID = nil
reblogHStack.isHidden = true
mainContainerTopToReblogLabelConstraint.isActive = false
mainContainerTopToSelfConstraint.isActive = true
} }
if showFollowedHashtags {
let hashtags = mastodonController.followedHashtags.filter({ followed in status.hashtags.contains(where: { followed.name == $0.name }) })
if !hashtags.isEmpty {
hideTimelineReason = false
timelineReasonIcon.image = hashtagIcon
timelineReasonLabel.text = hashtags.map(\.name).formatted(.list(type: .and, width: .narrow))
timelineReasonLabel.removeEmojis()
}
}
timelineReasonHStack.isHidden = hideTimelineReason
mainContainerTopToReblogLabelConstraint.isActive = !hideTimelineReason
mainContainerTopToSelfConstraint.isActive = hideTimelineReason
doUpdateUI(status: status) doUpdateUI(status: status)
doUpdateTimestamp(status: status) doUpdateTimestamp(status: status)
@ -523,11 +539,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private func updateRebloggerLabel(reblogger: AccountMO) { private func updateRebloggerLabel(reblogger: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames { if Preferences.shared.hideCustomEmojiInUsernames {
rebloggerLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged" timelineReasonLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
rebloggerLabel.removeEmojis() timelineReasonLabel.removeEmojis()
} else { } else {
rebloggerLabel.text = "\(reblogger.displayOrUserName) reblogged" timelineReasonLabel.text = "\(reblogger.displayOrUserName) reblogged"
rebloggerLabel.setEmojis(reblogger.emojis, identifier: reblogger.id) timelineReasonLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
} }
} }
@ -566,10 +582,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
// MARK: Interaction // MARK: Interaction
@objc private func reblogLabelPressed() { @objc private func reblogLabelPressed() {
guard let rebloggerID else { if let rebloggerID {
return delegate?.selected(account: rebloggerID)
} else if showFollowedHashtags,
let status = mastodonController.persistentContainer.status(for: statusID),
let hashtag = mastodonController.followedHashtags.first(where: { followed in status.hashtags.contains(where: { followed.name == $0.name }) }) {
delegate?.selected(tag: Hashtag(name: hashtag.name, url: hashtag.url))
} }
delegate?.selected(account: rebloggerID)
} }
@objc private func accountPressed() { @objc private func accountPressed() {
@ -634,7 +653,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController) ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController)
} actionProvider: { _ in } actionProvider: { _ in
UIMenu(children: self.delegate!.actionsForStatus(status, sourceView: self)) UIMenu(children: self.delegate!.actionsForStatus(status, source: .view(self)))
} }
} }

View File

@ -377,7 +377,7 @@ extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? []) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [])
} }
} }
@ -405,7 +405,7 @@ extension TimelineStatusTableViewCell: MenuPreviewProvider {
} }
return ( return (
content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) }, content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) },
actions: { self.delegate?.actionsForStatus(status, sourceView: self) ?? [] } actions: { self.delegate?.actionsForStatus(status, source: .view(self)) ?? [] }
) )
} }
} }

View File

@ -27,7 +27,7 @@ extension ToastableViewController {
} }
set { set {
if let newValue = newValue { if let newValue = newValue {
objc_setAssociatedObject(self, currentToastKey, WeakHolder(object: newValue), .OBJC_ASSOCIATION_RETAIN) objc_setAssociatedObject(self, currentToastKey, WeakHolder(newValue), .OBJC_ASSOCIATION_RETAIN)
} else { } else {
objc_setAssociatedObject(self, currentToastKey, nil, .OBJC_ASSOCIATION_RETAIN) objc_setAssociatedObject(self, currentToastKey, nil, .OBJC_ASSOCIATION_RETAIN)
} }
@ -96,11 +96,3 @@ extension ToastableViewController {
} }
} }
fileprivate class WeakHolder<T: AnyObject> {
weak var object: T?
init(object: T) {
self.object = object
}
}

View File

@ -1,5 +1,5 @@
// //
// WeakArray.swift // Weak.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 3/25/20. // Created by Shadowfacts on 3/25/20.
@ -8,16 +8,16 @@
import Foundation import Foundation
fileprivate class WeakWrapper<T: AnyObject> { class WeakHolder<T: AnyObject> {
weak var value: T? weak var object: T?
init(_ value: T?) { init(_ object: T?) {
self.value = value self.object = object
} }
} }
struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection { struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection {
private var array: [WeakWrapper<Element>] private var array: [WeakHolder<Element>]
var startIndex: Int { array.startIndex } var startIndex: Int { array.startIndex }
var endIndex: Int { array.endIndex } var endIndex: Int { array.endIndex }
@ -27,19 +27,19 @@ struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollect
} }
init(_ elements: [Element]) { init(_ elements: [Element]) {
array = elements.map { WeakWrapper($0) } array = elements.map { WeakHolder($0) }
} }
init(_ elements: [Element?]) { init(_ elements: [Element?]) {
array = elements.map { WeakWrapper($0) } array = elements.map { WeakHolder($0) }
} }
subscript(position: Int) -> Element? { subscript(position: Int) -> Element? {
get { get {
array[position].value array[position].object
} }
set(newValue) { set(newValue) {
array[position] = WeakWrapper(newValue) array[position] = WeakHolder(newValue)
} }
} }
@ -48,6 +48,6 @@ struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollect
} }
mutating func replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C) where C : Collection, Self.Element == C.Element { mutating func replaceSubrange<C>(_ subrange: Range<Int>, with newElements: C) where C : Collection, Self.Element == C.Element {
array.replaceSubrange(subrange, with: newElements.map { WeakWrapper($0) }) array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) })
} }
} }