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]))
}
public static func getFollowedHashtags() -> Request<[Hashtag]> {
return Request(method: .get, path: "/api/v1/followed_tags")
}
// MARK: - Lists
public static func getLists() -> Request<[List]> {
return Request<[List]>(method: .get, path: "/api/v1/lists")

View File

@ -15,11 +15,14 @@ public class Hashtag: Codable {
public let url: WebURL
/// Only present when returned from the trending hashtags endpoint
public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in
public let following: Bool?
public init(name: String, url: URL) {
self.name = name
self.url = WebURL(url)!
self.history = nil
self.following = nil
}
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
self.url = try container.decode(WebURL.self, forKey: .url)
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 {
@ -35,12 +39,22 @@ public class Hashtag: Codable {
try container.encode(name, forKey: .name)
try container.encode(url, forKey: .url)
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 {
case name
case url
case history
case following
}
}

View File

@ -50,6 +50,8 @@
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.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 */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.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 */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.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 */; };
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; };
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>"; };
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>"; };
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>"; };
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>"; };
@ -666,7 +670,7 @@
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>"; };
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>"; };
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>"; };
@ -881,6 +885,7 @@
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
);
@ -1418,7 +1423,7 @@
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D6DFC69F242C4CCC00ACC392 /* Weak.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
@ -1526,6 +1531,7 @@
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
D6F6A551291F098700F496A8 /* RenameListService.swift */,
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1824,6 +1830,7 @@
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
@ -1994,6 +2001,7 @@
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
@ -2006,7 +2014,7 @@
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.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?) {
let ver = instance.version.lowercased()
if ver.contains("glitch") {

View File

@ -8,6 +8,7 @@
import Foundation
import Pachyderm
import Combine
class MastodonController: ObservableObject {
@ -47,7 +48,10 @@ class MastodonController: ObservableObject {
@Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var instanceFeatures = InstanceFeatures()
@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 ownInstanceRequest: URLSessionTask?
@ -61,6 +65,29 @@ class MastodonController: ObservableObject {
self.accountInfo = nil
self.client = Client(baseURL: instanceURL, session: .appDefault)
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
@ -230,7 +257,6 @@ class MastodonController: ObservableObject {
DispatchQueue.main.async {
self.ownInstanceRequest = nil
self.instance = instance
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.success(instance))
@ -248,9 +274,6 @@ class MastodonController: ObservableObject {
case let .success(nodeInfo, _):
DispatchQueue.main.async {
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
}
@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 {

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) {
let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags {

View File

@ -28,6 +28,10 @@
</uniquenessConstraint>
</uniquenessConstraints>
</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">
<attribute name="accountID" optional="YES" attributeType="String"/>
<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 {
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)
}
// bold to more closesly match other action symbols

View File

@ -88,7 +88,7 @@ extension AccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} 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 ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
} 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)
}

View File

@ -98,7 +98,7 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} 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) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} 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):

View File

@ -206,7 +206,7 @@ extension StatusActionAccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} 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
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 {
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) {
self.hashtag = hashtag
@ -39,19 +36,16 @@ class HashtagTimelineViewController: TimelineViewController {
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)
let menu = UIMenu(children: [
// uncached so that the saved/followed updates every time
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
})
])
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
}
@objc func savedHashtagsChanged() {
toggleSaveButton.title = toggleSaveButtonTitle
}
// MARK: - Interaction
@objc func toggleSaveButtonPressed() {
private func toggleSave() {
let context = mastodonController.persistentContainer.viewContext
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(existing)
@ -60,5 +54,11 @@ class HashtagTimelineViewController: TimelineViewController {
}
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
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
cell.delegate = self
if case .home = timeline {
cell.showFollowedHashtags = true
} else {
cell.showFollowedHashtags = false
}
cell.updateUI(statusID: id, state: state)
}
@ -343,21 +348,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot()
let snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else {
return
}
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
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) {
// remove any existing gap, if there is one
if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...]))
}
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
// create a new snapshot to reset the timeline to the "present" state
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, state: .unknown) }, toSection: .statuses)
var config = ToastConfiguration(title: "Jump to present")
config.edge = .top
@ -366,12 +369,34 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.action = { [unowned self] toast in
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) {
// 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)
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)
@ -380,32 +405,35 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// NOTE: this only works when items are being inserted ABOVE the item to maintain
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
if let indexPath = dataSource.indexPath(for: itemToMaintain),
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
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) {
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
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemAfterOriginalGap
var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0
while true {
if cur.row <= 0 {
break
}
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop {
if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
break
}
cur = IndexPath(row: cur.row - 1, section: cur.section)
@ -415,7 +443,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
amountScrolledUp += attrs.size.height
}
self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop
self.collectionView.contentOffset.y -= offsetFromTop
}
snapshotView.removeFromSuperview()

View File

@ -39,15 +39,15 @@ extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] {
func actionsForProfile(accountID: String, source: PopoverSource) -> [UIMenuElement] {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
var shareSection = [
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 }
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 [
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 }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
})
]
}
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
let actionsSection: [UIMenuElement]
func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
var actionsSection: [UIMenuElement] = []
if let mastodonController = mastodonController,
mastodonController.loggedIn {
let name = hashtag.name.lowercased()
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 = [
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 {
context.delete(existing)
} else {
@ -121,13 +124,21 @@ extension MenuActionProvider {
mastodonController.persistentContainer.save(context: context)
})
]
} else {
actionsSection = []
if mastodonController.instanceFeatures.canFollowHashtags {
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]
if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, sourceView: sourceView)
shareSection = actionsForURL(url, source: source)
} else {
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 accountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
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 }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
})
]
}
@ -271,9 +282,9 @@ extension MenuActionProvider {
} else {
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 }
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))

View File

@ -159,21 +159,21 @@ extension TuskerNavigationDelegate {
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)
vc.popoverPresentationController?.sourceView = sourceView
source.apply(to: vc)
present(vc, animated: true)
}
func showMoreOptions(forURL url: URL, sourceView: UIView?) {
func showMoreOptions(forURL url: URL, source: PopoverSource) {
let vc = moreOptions(forURL: url)
vc.popoverPresentationController?.sourceView = sourceView
source.apply(to: vc)
present(vc, animated: true)
}
func showMoreOptions(forAccount accountID: String, sourceView: UIView?) {
func showMoreOptions(forAccount accountID: String, source: PopoverSource) {
let vc = moreOptions(forAccount: accountID)
vc.popoverPresentationController?.sourceView = sourceView
source.apply(to: vc)
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 }
return (
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 actions: [UIMenuElement]
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) {
actions = self.actionsForHashtag(tag, sourceView: self)
actions = self.actionsForHashtag(tag, source: .view(self))
} else {
actions = self.actionsForURL(link, sourceView: self)
actions = self.actionsForURL(link, source: .view(self))
}
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}

View File

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

View File

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

View File

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

View File

@ -122,7 +122,7 @@ class ProfileHeaderView: UIView {
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.setTextFromHtml(account.note)

View File

@ -209,7 +209,7 @@ class BaseStatusTableViewCell: UITableViewCell {
// 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
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.mastodonController = mastodonController
@ -408,7 +408,7 @@ class BaseStatusTableViewCell: UITableViewCell {
}
@IBAction func morePressed() {
delegate?.showMoreOptions(forStatus: statusID, sourceView: moreButton)
delegate?.showMoreOptions(forStatus: statusID, source: .view(moreButton))
}
@objc func accountPressed() {

View File

@ -135,7 +135,7 @@ extension ConversationMainStatusTableViewCell: UIContextMenuInteractionDelegate
return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} 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 SFSafariViewController(url: URL(card.url)!)
} 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)
}
}

View File

@ -195,7 +195,7 @@ extension StatusCollectionViewCell {
// 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
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.mastodonController = mastodonController
@ -257,7 +257,7 @@ extension StatusCollectionViewCell {
return UIContextMenuConfiguration() {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} 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 Combine
private let reblogIcon = UIImage(systemName: "repeat")
private let hashtagIcon = UIImage(systemName: "number")
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
// MARK: Subviews
private lazy var rebloggerLabel = EmojiLabel().configure {
private lazy var timelineReasonLabel = EmojiLabel().configure {
$0.textColor = .secondaryLabel
$0.font = .preferredFont(forTextStyle: .body)
$0.adjustsFontForContentSizeCategory = true
}
private let reblogIcon = UIImageView(image: UIImage(systemName: "repeat")).configure {
private let timelineReasonIcon = UIImageView(image: reblogIcon).configure {
$0.tintColor = .secondaryLabel
}
private lazy var reblogHStack = UIStackView(arrangedSubviews: [
reblogIcon,
rebloggerLabel,
private lazy var timelineReasonHStack = UIStackView(arrangedSubviews: [
timelineReasonIcon,
timelineReasonLabel,
]).configure {
$0.axis = .horizontal
$0.spacing = 8
@ -258,6 +261,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
true
}
var showPinned: Bool = false
var showFollowedHashtags: Bool = false
// alas these need to be internal so they're accessible from the protocol extensions
var statusID: String!
@ -275,12 +279,12 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
override init(frame: CGRect) {
super.init(frame: frame)
for subview in [reblogHStack, mainContainer, actionsContainer] {
for subview in [timelineReasonHStack, mainContainer, actionsContainer] {
subview.translatesAutoresizingMaskIntoConstraints = false
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)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -6)
@ -291,9 +295,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
NSLayoutConstraint.activate([
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
reblogHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
rebloggerLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
reblogHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16),
timelineReasonHStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4),
timelineReasonLabel.leadingAnchor.constraint(equalTo: contentVStack.leadingAnchor),
timelineReasonHStack.trailingAnchor.constraint(lessThanOrEqualTo: contentView.trailingAnchor, constant: -16),
mainContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
mainContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
@ -427,23 +431,35 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
self.statusState = state
var hideTimelineReason = true
if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
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
} else {
reblogStatusID = 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)
doUpdateTimestamp(status: status)
@ -523,11 +539,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
private func updateRebloggerLabel(reblogger: AccountMO) {
if Preferences.shared.hideCustomEmojiInUsernames {
rebloggerLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
rebloggerLabel.removeEmojis()
timelineReasonLabel.text = "\(reblogger.displayNameWithoutCustomEmoji) reblogged"
timelineReasonLabel.removeEmojis()
} else {
rebloggerLabel.text = "\(reblogger.displayOrUserName) reblogged"
rebloggerLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
timelineReasonLabel.text = "\(reblogger.displayOrUserName) reblogged"
timelineReasonLabel.setEmojis(reblogger.emojis, identifier: reblogger.id)
}
}
@ -566,10 +582,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
// MARK: Interaction
@objc private func reblogLabelPressed() {
guard let rebloggerID else {
return
if let rebloggerID {
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() {
@ -634,7 +653,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
return UIContextMenuConfiguration {
ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: self.mastodonController)
} 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) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} 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 (
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 {
if let newValue = newValue {
objc_setAssociatedObject(self, currentToastKey, WeakHolder(object: newValue), .OBJC_ASSOCIATION_RETAIN)
objc_setAssociatedObject(self, currentToastKey, WeakHolder(newValue), .OBJC_ASSOCIATION_RETAIN)
} else {
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
//
// Created by Shadowfacts on 3/25/20.
@ -8,16 +8,16 @@
import Foundation
fileprivate class WeakWrapper<T: AnyObject> {
weak var value: T?
class WeakHolder<T: AnyObject> {
weak var object: T?
init(_ value: T?) {
self.value = value
init(_ object: T?) {
self.object = object
}
}
struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollection {
private var array: [WeakWrapper<Element>]
private var array: [WeakHolder<Element>]
var startIndex: Int { array.startIndex }
var endIndex: Int { array.endIndex }
@ -27,19 +27,19 @@ struct WeakArray<Element: AnyObject>: MutableCollection, RangeReplaceableCollect
}
init(_ elements: [Element]) {
array = elements.map { WeakWrapper($0) }
array = elements.map { WeakHolder($0) }
}
init(_ elements: [Element?]) {
array = elements.map { WeakWrapper($0) }
array = elements.map { WeakHolder($0) }
}
subscript(position: Int) -> Element? {
get {
array[position].value
array[position].object
}
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 {
array.replaceSubrange(subrange, with: newElements.map { WeakWrapper($0) })
array.replaceSubrange(subrange, with: newElements.map { WeakHolder($0) })
}
}