From 964cef0ff5d4c1180cd39246cc65a55acf88fc4a Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Tue, 25 Feb 2025 13:13:40 -0500 Subject: [PATCH] Author attribution on status cards Closes #542 --- .../Sources/Pachyderm/Model/Card.swift | 33 ++- Tusker.xcodeproj/project.pbxproj | 4 + Tusker/CoreData/StatusCardMO.swift | 68 ++++++ Tusker/CoreData/StatusMO.swift | 24 +- .../Tusker.xcdatamodel/contents | 15 +- .../InstanceSelectorTableViewController.swift | 1 - .../Appearance/MockStatusView.swift | 5 +- .../StatusEditCollectionViewCell.swift | 4 +- ...ersationMainStatusCollectionViewCell.swift | 4 +- Tusker/Views/Status/StatusCardView.swift | 228 +++++++++++++----- .../Status/StatusCollectionViewCell.swift | 2 +- .../Views/Status/StatusContentContainer.swift | 9 - .../TimelineStatusCollectionViewCell.swift | 4 +- 13 files changed, 307 insertions(+), 94 deletions(-) create mode 100644 Tusker/CoreData/StatusCardMO.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift index f3dbd14e..56d41d58 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Card.swift @@ -25,6 +25,7 @@ public struct Card: Codable, Sendable { public let blurhash: String? /// Only present when returned from the trending links endpoint public let history: [History]? + public let authors: [Author] public init( url: WebURL, @@ -40,7 +41,8 @@ public struct Card: Codable, Sendable { width: Int? = nil, height: Int? = nil, blurhash: String? = nil, - history: [History]? = nil + history: [History]? = nil, + authors: [Author] = [] ) { self.url = url self.title = title @@ -56,6 +58,7 @@ public struct Card: Codable, Sendable { self.height = height self.blurhash = blurhash self.history = history + self.authors = authors } public init(from decoder: Decoder) throws { @@ -75,6 +78,7 @@ public struct Card: Codable, Sendable { self.height = try? container.decodeIfPresent(Int.self, forKey: .height) self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash) self.history = try? container.decodeIfPresent([History].self, forKey: .history) + self.authors = try container.decodeIfPresent([Author].self, forKey: .authors) ?? [] } public func encode(to encoder: Encoder) throws { @@ -103,6 +107,7 @@ public struct Card: Codable, Sendable { case height case blurhash case history + case authors } } @@ -114,3 +119,29 @@ extension Card { case rich } } + +extension Card { + public struct Author: Decodable, Sendable { + public let name: String + public let url: WebURL? + public let account: Account? + + enum CodingKeys: CodingKey { + case name + case url + case account + } + + public init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decode(String.self, forKey: .name) + let s = try container.decode(String.self, forKey: .url) + if s.isEmpty { + self.url = nil + } else { + self.url = WebURL(s) + } + self.account = try container.decodeIfPresent(Account.self, forKey: .account) + } + } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index e779ee3c..bc46adc4 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -366,6 +366,7 @@ D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; + D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */; }; D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; }; D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; }; D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; }; @@ -806,6 +807,7 @@ D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = ""; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = ""; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = ""; }; + D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardMO.swift; sourceTree = ""; }; D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = ""; }; D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = ""; }; D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = ""; }; @@ -1032,6 +1034,7 @@ children = ( D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */, D60E2F232442372B005F8713 /* StatusMO.swift */, + D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */, D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D6B9366E2828452F00237D0E /* SavedHashtag.swift */, @@ -2279,6 +2282,7 @@ D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, D659F36229541065002D944A /* TTTView.swift in Sources */, D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */, + D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */, diff --git a/Tusker/CoreData/StatusCardMO.swift b/Tusker/CoreData/StatusCardMO.swift new file mode 100644 index 00000000..2e142e07 --- /dev/null +++ b/Tusker/CoreData/StatusCardMO.swift @@ -0,0 +1,68 @@ +// +// StatusCardMO.swift +// Tusker +// +// Created by Shadowfacts on 2/25/25. +// Copyright © 2025 Shadowfacts. All rights reserved. +// + +import Foundation +import CoreData +import Pachyderm +import WebURLFoundationExtras + +@objc(StatusCardMO) +public final class StatusCardMO: NSManagedObject { + + @NSManaged public var url: URL + @NSManaged public var title: String + @NSManaged public var cardDescription: String + @NSManaged private var kindString: String + @NSManaged public var image: URL? + @NSManaged public var blurhash: String? + + @NSManaged public var authors: NSSet + @NSManaged public var status: StatusMO + + public var authorAccounts: Set { + authors as! Set + } + + public var kind: Card.Kind { + get { .init(rawValue: kindString) ?? .link } + set { kindString = newValue.rawValue } + } + +} + +extension StatusCardMO { + convenience init(apiCard card: Card, status: StatusMO, container: MastodonCachePersistentStore, context: NSManagedObjectContext) { + self.init(context: context) + self.updateFrom(apiCard: card, container: container) + self.status = status + } + + func updateFrom(apiCard card: Card, container: MastodonCachePersistentStore) { + guard let context = managedObjectContext else { + return + } + + self.url = URL(card.url)! + self.title = card.title + self.cardDescription = card.description + self.kind = card.kind + self.image = card.image.flatMap { URL($0) } + self.blurhash = card.blurhash + + let authors = NSMutableSet() + for account in card.authors.compactMap(\.account) { + if let existing = container.account(for: account.id, in: context) { + authors.add(existing) + } else { + let new = AccountMO(apiAccount: account, container: container, context: context) + authors.add(new) + } + } + self.authors = authors + } +} diff --git a/Tusker/CoreData/StatusMO.swift b/Tusker/CoreData/StatusMO.swift index 364480cc..0ee0fe59 100644 --- a/Tusker/CoreData/StatusMO.swift +++ b/Tusker/CoreData/StatusMO.swift @@ -50,12 +50,14 @@ public final class StatusMO: NSManagedObject, StatusProtocol { @NSManaged public var url: URL? @NSManaged private var visibilityString: String @NSManaged private var pollData: Data? - @NSManaged public var account: AccountMO - @NSManaged public var reblog: StatusMO? @NSManaged public var localOnly: Bool @NSManaged public var lastFetchedAt: Date? @NSManaged public var language: String? + @NSManaged public var account: AccountMO + @NSManaged public var reblog: StatusMO? + @NSManaged public var card: StatusCardMO? + @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) public var attachments: [Attachment] @@ -68,8 +70,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol { @LazilyDecoding(arrayFrom: \StatusMO.mentionsData) public var mentions: [Mention] + // The card deserialized from cardData. This is only kept around for when migrating forward to the version that added StatusCardMO. @LazilyDecoding(from: \StatusMO.cardData, fallback: nil) - public var card: Card? + public var deprecatedCard: Card? @LazilyDecoding(from: \StatusMO.pollData, fallback: nil) public var poll: Poll? @@ -117,7 +120,6 @@ extension StatusMO { self.applicationName = status.application?.name self.attachments = status.attachments self.bookmarkedInternal = status.bookmarked ?? false - self.card = status.card self.content = status.content self.createdAt = status.createdAt self.editedAt = status.editedAt @@ -158,5 +160,19 @@ extension StatusMO { } else { self.reblog = nil } + if let card = status.card { + if let existing = self.card { + existing.updateFrom(apiCard: card, container: container) + } else { + let new = StatusCardMO(apiCard: card, status: self, container: container, context: context) + self.card = new + } + self.deprecatedCard = nil + } else { + if let existing = self.card { + context.delete(existing) + self.card = nil + } + } } } diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 42e959ed..82982b2d 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -20,6 +20,7 @@ + @@ -125,6 +126,7 @@ + @@ -134,6 +136,16 @@ + + + + + + + + + + @@ -162,5 +174,6 @@ + \ No newline at end of file diff --git a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift index df867120..202383d0 100644 --- a/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift +++ b/Tusker/Screens/Onboarding/InstanceSelectorTableViewController.swift @@ -254,7 +254,6 @@ class InstanceSelectorTableViewController: UITableViewController { private func showRecommendationsError(_ error: Client.ErrorType) { let footer = UITableViewHeaderFooterView() - footer.translatesAutoresizingMaskIntoConstraints = false let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false diff --git a/Tusker/Screens/Preferences/Appearance/MockStatusView.swift b/Tusker/Screens/Preferences/Appearance/MockStatusView.swift index 5cbd46e2..003b33fb 100644 --- a/Tusker/Screens/Preferences/Appearance/MockStatusView.swift +++ b/Tusker/Screens/Preferences/Appearance/MockStatusView.swift @@ -46,7 +46,6 @@ struct MockStatusView: View { if preferences.showLinkPreviews { MockStatusCardView() - .frame(height: StatusContentContainer.cardViewHeight) } MockAttachmentsContainerView() @@ -136,8 +135,8 @@ private struct MockStatusCardView: UIViewRepresentable { let view = StatusCardView() view.isUserInteractionEnabled = false let card = StatusCardView.CardData( - url: WebURL("https://vaccor.space/tusker")!, - image: WebURL("https://vaccor.space/tusker/img/icon.png")!, + url: URL(string: "https://vaccor.space/tusker")!, + image: URL(string: "https://vaccor.space/tusker/img/icon.png")!, title: "Tusker", description: "Tusker is an iOS app for Mastodon" ) diff --git a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift index b29ce12f..9e9b9ff2 100644 --- a/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift +++ b/Tusker/Screens/Status Edit History/StatusEditCollectionViewCell.swift @@ -79,9 +79,7 @@ class StatusEditCollectionViewCell: UICollectionViewListCell { $0.isSelectable = false } - private let cardView = StatusCardView().configure { - $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true - } + private let cardView = StatusCardView() private let attachmentsView = AttachmentsContainerView() diff --git a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift index a3cc2ee4..032b7a9d 100644 --- a/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/ConversationMainStatusCollectionViewCell.swift @@ -151,9 +151,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status private var translateButton: TranslateButton? - let cardView = StatusCardView().configure { - $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true - } + let cardView = StatusCardView() let attachmentsView = AttachmentsContainerView() diff --git a/Tusker/Views/Status/StatusCardView.swift b/Tusker/Views/Status/StatusCardView.swift index fd07830d..e47b6e70 100644 --- a/Tusker/Views/Status/StatusCardView.swift +++ b/Tusker/Views/Status/StatusCardView.swift @@ -15,6 +15,8 @@ import HTMLStreamer class StatusCardView: UIView { + fileprivate static var cardHeight: CGFloat { 80 } + weak var navigationDelegate: TuskerNavigationDelegate? weak var actionProvider: MenuActionProvider? @@ -35,6 +37,10 @@ class StatusCardView: UIView { private var placeholderImageView: UIImageView! private var leadingSpacer: UIView! private var trailingSpacer: UIView! + private var authorContainerVStack: UIStackView! + private var authorHStack: UIStackView? + private var authorAvatarImageView: CachedImageView? + private var authorDisplayNameLabel: EmojiLabel? override init(frame: CGRect) { super.init(frame: frame) @@ -47,25 +53,14 @@ class StatusCardView: UIView { } private func commonInit() { - self.layer.shadowColor = UIColor.black.cgColor - self.layer.shadowRadius = 5 - self.layer.shadowOpacity = 0.2 - self.layer.shadowOffset = .zero - - self.addInteraction(UIContextMenuInteraction(delegate: self)) - titleLabel = UILabel() titleLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0) titleLabel.adjustsFontForContentSizeCategory = true - titleLabel.numberOfLines = 2 - titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) descriptionLabel = UILabel() descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0) descriptionLabel.adjustsFontForContentSizeCategory = true - descriptionLabel.numberOfLines = 3 - descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical) - + domainLabel = UILabel() domainLabel.font = .preferredFont(forTextStyle: .caption2) domainLabel.adjustsFontForContentSizeCategory = true @@ -83,7 +78,8 @@ class StatusCardView: UIView { ]) vStack.axis = .vertical vStack.alignment = .leading - vStack.spacing = 0 + vStack.distribution = .equalSpacing + vStack.spacing = 2 imageView = StatusCardImageView(cache: .attachments) imageView.contentMode = .scaleAspectFill @@ -100,29 +96,38 @@ class StatusCardView: UIView { vStack, trailingSpacer, ]) - hStack.translatesAutoresizingMaskIntoConstraints = false hStack.axis = .horizontal hStack.alignment = .center hStack.distribution = .fill hStack.spacing = 4 hStack.clipsToBounds = true hStack.layer.borderWidth = 0.5 + hStack.layer.cornerRadius = 5 hStack.layer.cornerCurve = .continuous hStack.backgroundColor = StatusCardView.inactiveBackgroundColor updateBorderColor() - addSubview(hStack) - + hStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped))) + hStack.addInteraction(UIContextMenuInteraction(delegate: self)) + placeholderImageView = UIImageView(image: UIImage(systemName: "doc.text")) placeholderImageView.translatesAutoresizingMaskIntoConstraints = false placeholderImageView.contentMode = .scaleAspectFit placeholderImageView.tintColor = .gray placeholderImageView.isHidden = true - addSubview(placeholderImageView) + authorContainerVStack = UIStackView(arrangedSubviews: [ + hStack, + ]) + authorContainerVStack.translatesAutoresizingMaskIntoConstraints = false + authorContainerVStack.axis = .vertical + authorContainerVStack.alignment = .leading + authorContainerVStack.spacing = 4 + addSubview(authorContainerVStack) + NSLayoutConstraint.activate([ - imageView.heightAnchor.constraint(equalTo: heightAnchor), + imageView.heightAnchor.constraint(equalToConstant: Self.cardHeight), imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor), vStack.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor, constant: -8), @@ -130,31 +135,40 @@ class StatusCardView: UIView { leadingSpacer.widthAnchor.constraint(equalToConstant: 4), trailingSpacer.widthAnchor.constraint(equalToConstant: 4), - hStack.leadingAnchor.constraint(equalTo: leadingAnchor), - hStack.trailingAnchor.constraint(equalTo: trailingAnchor), - hStack.topAnchor.constraint(equalTo: topAnchor), - hStack.bottomAnchor.constraint(equalTo: bottomAnchor), + hStack.heightAnchor.constraint(equalToConstant: Self.cardHeight), + hStack.widthAnchor.constraint(equalTo: widthAnchor), placeholderImageView.widthAnchor.constraint(equalToConstant: 30), placeholderImageView.heightAnchor.constraint(equalToConstant: 30), placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), + + authorContainerVStack.leadingAnchor.constraint(equalTo: leadingAnchor), + authorContainerVStack.trailingAnchor.constraint(equalTo: trailingAnchor), + authorContainerVStack.topAnchor.constraint(equalTo: topAnchor), + authorContainerVStack.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + updateContentSize() + + #if os(visionOS) + registerForTraitChanges([UITraitPreferredContentSizeCategory.self], action: #selector(updateContentSize)) + #endif } // Unneeded on visionOS because there is no light/dark mode #if !os(visionOS) override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - updateBorderColor() + if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle { + updateBorderColor() + } + if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { + updateContentSize() + } } #endif - override func layoutSubviews() { - super.layoutSubviews() - hStack.layer.cornerRadius = 0.1 * bounds.height - } - private func updateBorderColor() { if traitCollection.userInterfaceStyle == .dark { hStack.layer.borderColor = UIColor.darkGray.cgColor @@ -163,8 +177,21 @@ class StatusCardView: UIView { } } - func updateUI(status: StatusMO) { - let newData = status.card.map { CardData(card: $0) } + @objc private func updateContentSize() { + let category = traitCollection.preferredContentSizeCategory + titleLabel.numberOfLines = if category > .extraExtraExtraLarge { + 2 + } else if category > .extraLarge { + 1 + } else { + 2 + } + descriptionLabel.isHidden = category > .extraExtraExtraLarge || (descriptionLabel.text?.isEmpty ?? true) + domainLabel.isHidden = category > .accessibilityMedium + } + + func updateUI(status: StatusMO, persistentContainer: MastodonCachePersistentStore) { + let newData = status.card.map { CardData(card: $0) } ?? status.deprecatedCard.map { CardData(card: $0) } guard self.card != newData else { return } @@ -176,6 +203,13 @@ class StatusCardView: UIView { } updateUI(card: newData, sensitive: status.sensitive) + + if let authorID = newData.authorID, + let account = persistentContainer.account(for: authorID) { + createCardAuthorView(account: account) + } else { + authorHStack?.isHidden = true + } } // This method is internal for use by MockStatusView @@ -184,14 +218,14 @@ class StatusCardView: UIView { if sensitive { if let blurhash = card.blurhash { imageView.blurImage = false - imageView.showOnlyBlurHash(blurhash, for: URL(image)!) + imageView.showOnlyBlurHash(blurhash, for: image) } else { // if we don't have a blurhash, load the image and show it behind a blur imageView.blurImage = true - imageView.update(for: URL(image), blurhash: nil) + imageView.update(for: image, blurhash: nil) } } else { - imageView.update(for: URL(image), blurhash: card.blurhash) + imageView.update(for: image, blurhash: card.blurhash) } imageView.isHidden = false leadingSpacer.isHidden = true @@ -208,66 +242,124 @@ class StatusCardView: UIView { let converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) let description = converter.convert(html: card.description) descriptionLabel.text = description - descriptionLabel.isHidden = description.isEmpty + descriptionLabel.isHidden = description.isEmpty || traitCollection.preferredContentSizeCategory > .extraExtraExtraLarge if let host = card.url.host { - domainLabel.text = host.serialized + domainLabel.text = host domainLabel.isHidden = false } else { domainLabel.isHidden = true } + } + + private func createCardAuthorView(account: AccountMO) { + if let authorHStack { + authorHStack.isHidden = false + } else { + let moreFromLabel = UILabel() + moreFromLabel.translatesAutoresizingMaskIntoConstraints = false + moreFromLabel.font = .preferredFont(forTextStyle: .subheadline) + moreFromLabel.adjustsFontForContentSizeCategory = true + moreFromLabel.text = "More from" + moreFromLabel.textColor = .secondaryLabel + + let avatarImageView = CachedImageView(cache: .avatars) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.layer.cornerCurve = .continuous + avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 25 + authorAvatarImageView = avatarImageView + + let displayNameLabel = EmojiLabel() + displayNameLabel.translatesAutoresizingMaskIntoConstraints = false + displayNameLabel.font = .preferredFont(forTextStyle: .subheadline).withTraits(.traitBold) + displayNameLabel.adjustsFontForContentSizeCategory = true + displayNameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + authorDisplayNameLabel = displayNameLabel + + let disclosureIndicator = UIImageView() + disclosureIndicator.translatesAutoresizingMaskIntoConstraints = false + disclosureIndicator.image = UIImage(systemName: "chevron.forward") + disclosureIndicator.tintColor = .secondaryLabel + disclosureIndicator.preferredSymbolConfiguration = .init(weight: .light) + + let hStack = UIStackView(arrangedSubviews: [ + moreFromLabel, + avatarImageView, + displayNameLabel, + disclosureIndicator, + ]) + hStack.axis = .horizontal + hStack.spacing = 8 + hStack.alignment = .center + authorHStack = hStack + + NSLayoutConstraint.activate([ + avatarImageView.widthAnchor.constraint(equalToConstant: 25), + avatarImageView.heightAnchor.constraint(equalToConstant: 25), + ]) + + authorContainerVStack.addArrangedSubview(hStack) + + authorHStack?.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(authorTapped))) + } - let titleHeight = titleLabel.isHidden ? 0 : titleLabel.sizeThatFits(CGSize(width: titleLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height - let descriptionHeight = descriptionLabel.isHidden ? 0 : descriptionLabel.sizeThatFits(CGSize(width: descriptionLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height - let domainLabel = domainLabel.isHidden ? 0 : domainLabel.sizeThatFits(CGSize(width: domainLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height - if titleHeight + descriptionHeight + domainLabel > vStack.bounds.height { - descriptionLabel.isHidden = true + authorAvatarImageView!.update(for: account.avatar) + if Preferences.shared.hideCustomEmojiInUsernames { + authorDisplayNameLabel!.text = account.displayNameWithoutCustomEmoji + authorDisplayNameLabel!.removeEmojis() + } else { + authorDisplayNameLabel!.text = account.displayOrUserName + authorDisplayNameLabel!.setEmojis(account.emojis, identifier: account.id) + } + } + + @objc private func cardTapped() { + if let card, let navigationDelegate { + navigationDelegate.selected(url: card.url) } } - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - hStack.backgroundColor = StatusCardView.activeBackgroundColor - setNeedsDisplay() - } - - override func touchesMoved(_ touches: Set, with event: UIEvent?) { - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - hStack.backgroundColor = StatusCardView.inactiveBackgroundColor - setNeedsDisplay() - - if let card = card, let delegate = navigationDelegate { - delegate.selected(url: URL(card.url)!) + @objc private func authorTapped() { + if let card, + let authorID = card.authorID, + let navigationDelegate { + navigationDelegate.selected(account: authorID) } } - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - hStack.backgroundColor = StatusCardView.inactiveBackgroundColor - setNeedsDisplay() - } - struct CardData: Equatable { - let url: WebURL - let image: WebURL? + let url: URL + let image: URL? let title: String let description: String let blurhash: String? + let authorID: String? init(card: Card) { - self.url = card.url - self.image = card.image + self.url = URL(card.url)! + self.image = card.image.flatMap { URL($0) } self.title = card.title self.description = card.description self.blurhash = card.blurhash + self.authorID = nil } - init(url: WebURL, image: WebURL? = nil, title: String, description: String, blurhash: String? = nil) { + init(card: StatusCardMO) { + self.url = card.url + self.image = card.image + self.title = card.title + self.description = card.cardDescription + self.blurhash = card.blurhash + self.authorID = card.authorAccounts.sorted(by: { $0.id < $1.id }).first?.id + } + + init(url: URL, image: URL? = nil, title: String, description: String, blurhash: String? = nil) { self.url = url self.image = image self.title = title self.description = description self.blurhash = blurhash + self.authorID = nil } } @@ -277,7 +369,7 @@ extension StatusCardView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { guard let card = card else { return nil } - return self.actionProvider?.contextMenuConfigurationForURL(URL(card.url)!, source: .view(self)) + return self.actionProvider?.contextMenuConfigurationForURL(card.url, source: .view(self)) } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { @@ -301,6 +393,12 @@ extension StatusCardView: UIContextMenuInteractionDelegate { } } +extension StatusCardView: StatusContentView { + func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { + Self.cardHeight + } +} + private class StatusCardImageView: CachedImageView { @Lazy private var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) var blurImage = false { diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 287514be..88eba361 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -102,7 +102,7 @@ extension StatusCollectionViewCell { pollView.delegate = delegate pollView.updateUI(status: status, poll: status.poll) if Preferences.shared.showLinkPreviews { - cardView.updateUI(status: status) + cardView.updateUI(status: status, persistentContainer: mastodonController.persistentContainer) cardView.isHidden = status.card == nil cardView.navigationDelegate = delegate cardView.actionProvider = delegate diff --git a/Tusker/Views/Status/StatusContentContainer.swift b/Tusker/Views/Status/StatusContentContainer.swift index add721fc..60c3d2b0 100644 --- a/Tusker/Views/Status/StatusContentContainer.swift +++ b/Tusker/Views/Status/StatusContentContainer.swift @@ -9,9 +9,6 @@ import UIKit class StatusContentContainer: UIView { - // TODO: this is a weird place for this - static var cardViewHeight: CGFloat { 90 } - private var arrangedSubviews: [any StatusContentView] private var isHiddenObservations: [NSKeyValueObservation] = [] @@ -206,12 +203,6 @@ extension ContentTextView: StatusContentView { } } -extension StatusCardView: StatusContentView { - func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { - StatusContentContainer.cardViewHeight - } -} - extension AttachmentsContainerView: StatusContentView { func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { effectiveWidth / aspectRatio diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 72d38f68..b1969abd 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -196,9 +196,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti $0.emojiFont = TimelineStatusCollectionViewCell.contentFont } - let cardView = StatusCardView().configure { - $0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true - } + let cardView = StatusCardView() let attachmentsView = AttachmentsContainerView()