Author attribution on status cards

Closes #542
This commit is contained in:
Shadowfacts 2025-02-25 13:13:40 -05:00
parent 8021868599
commit 964cef0ff5
13 changed files with 307 additions and 94 deletions

View File

@ -25,6 +25,7 @@ public struct Card: Codable, Sendable {
public let blurhash: String? public let blurhash: String?
/// Only present when returned from the trending links endpoint /// Only present when returned from the trending links endpoint
public let history: [History]? public let history: [History]?
public let authors: [Author]
public init( public init(
url: WebURL, url: WebURL,
@ -40,7 +41,8 @@ public struct Card: Codable, Sendable {
width: Int? = nil, width: Int? = nil,
height: Int? = nil, height: Int? = nil,
blurhash: String? = nil, blurhash: String? = nil,
history: [History]? = nil history: [History]? = nil,
authors: [Author] = []
) { ) {
self.url = url self.url = url
self.title = title self.title = title
@ -56,6 +58,7 @@ public struct Card: Codable, Sendable {
self.height = height self.height = height
self.blurhash = blurhash self.blurhash = blurhash
self.history = history self.history = history
self.authors = authors
} }
public init(from decoder: Decoder) throws { 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.height = try? container.decodeIfPresent(Int.self, forKey: .height)
self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash) self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash)
self.history = try? container.decodeIfPresent([History].self, forKey: .history) 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 { public func encode(to encoder: Encoder) throws {
@ -103,6 +107,7 @@ public struct Card: Codable, Sendable {
case height case height
case blurhash case blurhash
case history case history
case authors
} }
} }
@ -114,3 +119,29 @@ extension Card {
case rich 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<CodingKeys> = 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)
}
}
}

View File

@ -366,6 +366,7 @@
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; }; D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; 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 */; }; D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; };
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; }; D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.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 = "<group>"; }; D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardMO.swift; sourceTree = "<group>"; };
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; }; D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; };
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; }; D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; }; D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
@ -1032,6 +1034,7 @@
children = ( children = (
D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */, D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */,
D60E2F232442372B005F8713 /* StatusMO.swift */, D60E2F232442372B005F8713 /* StatusMO.swift */,
D6F3BE132D6E133C00F5E92D /* StatusCardMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */, D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
@ -2279,6 +2282,7 @@
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */, D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D659F36229541065002D944A /* TTTView.swift in Sources */, D659F36229541065002D944A /* TTTView.swift in Sources */,
D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */, D65B4B6229771A3F00DABDFB /* FetchStatusService.swift in Sources */,
D6F3BE142D6E133E00F5E92D /* StatusCardMO.swift in Sources */,
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */, D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */, 0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */, D6945C2F23AC47C3005C403C /* SavedDataManager.swift in Sources */,

View File

@ -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<AccountMO> {
authors as! Set<AccountMO>
}
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
}
}

View File

@ -50,12 +50,14 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var url: URL? @NSManaged public var url: URL?
@NSManaged private var visibilityString: String @NSManaged private var visibilityString: String
@NSManaged private var pollData: Data? @NSManaged private var pollData: Data?
@NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO?
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var lastFetchedAt: Date? @NSManaged public var lastFetchedAt: Date?
@NSManaged public var language: String? @NSManaged public var language: String?
@NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO?
@NSManaged public var card: StatusCardMO?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment] public var attachments: [Attachment]
@ -68,8 +70,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData) @LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
public var mentions: [Mention] 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) @LazilyDecoding(from: \StatusMO.cardData, fallback: nil)
public var card: Card? public var deprecatedCard: Card?
@LazilyDecoding(from: \StatusMO.pollData, fallback: nil) @LazilyDecoding(from: \StatusMO.pollData, fallback: nil)
public var poll: Poll? public var poll: Poll?
@ -117,7 +120,6 @@ extension StatusMO {
self.applicationName = status.application?.name self.applicationName = status.application?.name
self.attachments = status.attachments self.attachments = status.attachments
self.bookmarkedInternal = status.bookmarked ?? false self.bookmarkedInternal = status.bookmarked ?? false
self.card = status.card
self.content = status.content self.content = status.content
self.createdAt = status.createdAt self.createdAt = status.createdAt
self.editedAt = status.editedAt self.editedAt = status.editedAt
@ -158,5 +160,19 @@ extension StatusMO {
} else { } else {
self.reblog = nil 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
}
}
} }
} }

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22522" systemVersion="23B92" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23605" systemVersion="24D70" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -20,6 +20,7 @@
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" attributeType="URI"/> <attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
<relationship name="authoredCards" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StatusCard" inverseName="authors" inverseEntity="StatusCard"/>
<relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/> <relationship name="movedTo" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account"/>
<relationship name="relationship" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/> <relationship name="relationship" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="Relationship" inverseName="account" inverseEntity="Relationship"/>
<relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="account" inverseEntity="Status"/> <relationship name="statuses" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Status" inverseName="account" inverseEntity="Status"/>
@ -125,6 +126,7 @@
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="card" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="StatusCard" inverseName="status" inverseEntity="StatusCard"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/>
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/> <relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/> <relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
@ -134,6 +136,16 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<entity name="StatusCard" representedClassName="StatusCardMO" syncable="YES">
<attribute name="blurhash" optional="YES" attributeType="String"/>
<attribute name="cardDescription" attributeType="String"/>
<attribute name="image" optional="YES" attributeType="URI"/>
<attribute name="kindString" attributeType="String"/>
<attribute name="title" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<relationship name="authors" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Account" inverseName="authoredCards" inverseEntity="Account"/>
<relationship name="status" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="card" inverseEntity="Status"/>
</entity>
<entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES"> <entity name="TimelinePosition" representedClassName="TimelinePosition" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="centerStatusID" optional="YES" attributeType="String"/> <attribute name="centerStatusID" optional="YES" attributeType="String"/>
@ -162,5 +174,6 @@
<memberEntity name="List"/> <memberEntity name="List"/>
<memberEntity name="Account"/> <memberEntity name="Account"/>
<memberEntity name="ActiveInstance"/> <memberEntity name="ActiveInstance"/>
<memberEntity name="StatusCard"/>
</configuration> </configuration>
</model> </model>

View File

@ -254,7 +254,6 @@ class InstanceSelectorTableViewController: UITableViewController {
private func showRecommendationsError(_ error: Client.ErrorType) { private func showRecommendationsError(_ error: Client.ErrorType) {
let footer = UITableViewHeaderFooterView() let footer = UITableViewHeaderFooterView()
footer.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel() let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false

View File

@ -46,7 +46,6 @@ struct MockStatusView: View {
if preferences.showLinkPreviews { if preferences.showLinkPreviews {
MockStatusCardView() MockStatusCardView()
.frame(height: StatusContentContainer.cardViewHeight)
} }
MockAttachmentsContainerView() MockAttachmentsContainerView()
@ -136,8 +135,8 @@ private struct MockStatusCardView: UIViewRepresentable {
let view = StatusCardView() let view = StatusCardView()
view.isUserInteractionEnabled = false view.isUserInteractionEnabled = false
let card = StatusCardView.CardData( let card = StatusCardView.CardData(
url: WebURL("https://vaccor.space/tusker")!, url: URL(string: "https://vaccor.space/tusker")!,
image: WebURL("https://vaccor.space/tusker/img/icon.png")!, image: URL(string: "https://vaccor.space/tusker/img/icon.png")!,
title: "Tusker", title: "Tusker",
description: "Tusker is an iOS app for Mastodon" description: "Tusker is an iOS app for Mastodon"
) )

View File

@ -79,9 +79,7 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
$0.isSelectable = false $0.isSelectable = false
} }
private let cardView = StatusCardView().configure { private let cardView = StatusCardView()
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
private let attachmentsView = AttachmentsContainerView() private let attachmentsView = AttachmentsContainerView()

View File

@ -151,9 +151,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
private var translateButton: TranslateButton? private var translateButton: TranslateButton?
let cardView = StatusCardView().configure { let cardView = StatusCardView()
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
let attachmentsView = AttachmentsContainerView() let attachmentsView = AttachmentsContainerView()

View File

@ -15,6 +15,8 @@ import HTMLStreamer
class StatusCardView: UIView { class StatusCardView: UIView {
fileprivate static var cardHeight: CGFloat { 80 }
weak var navigationDelegate: TuskerNavigationDelegate? weak var navigationDelegate: TuskerNavigationDelegate?
weak var actionProvider: MenuActionProvider? weak var actionProvider: MenuActionProvider?
@ -35,6 +37,10 @@ class StatusCardView: UIView {
private var placeholderImageView: UIImageView! private var placeholderImageView: UIImageView!
private var leadingSpacer: UIView! private var leadingSpacer: UIView!
private var trailingSpacer: 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) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -47,24 +53,13 @@ class StatusCardView: UIView {
} }
private func commonInit() { 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 = UILabel()
titleLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0) titleLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0)
titleLabel.adjustsFontForContentSizeCategory = true titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.numberOfLines = 2
titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical)
descriptionLabel = UILabel() descriptionLabel = UILabel()
descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0) descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0)
descriptionLabel.adjustsFontForContentSizeCategory = true descriptionLabel.adjustsFontForContentSizeCategory = true
descriptionLabel.numberOfLines = 3
descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
domainLabel = UILabel() domainLabel = UILabel()
domainLabel.font = .preferredFont(forTextStyle: .caption2) domainLabel.font = .preferredFont(forTextStyle: .caption2)
@ -83,7 +78,8 @@ class StatusCardView: UIView {
]) ])
vStack.axis = .vertical vStack.axis = .vertical
vStack.alignment = .leading vStack.alignment = .leading
vStack.spacing = 0 vStack.distribution = .equalSpacing
vStack.spacing = 2
imageView = StatusCardImageView(cache: .attachments) imageView = StatusCardImageView(cache: .attachments)
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
@ -100,29 +96,38 @@ class StatusCardView: UIView {
vStack, vStack,
trailingSpacer, trailingSpacer,
]) ])
hStack.translatesAutoresizingMaskIntoConstraints = false
hStack.axis = .horizontal hStack.axis = .horizontal
hStack.alignment = .center hStack.alignment = .center
hStack.distribution = .fill hStack.distribution = .fill
hStack.spacing = 4 hStack.spacing = 4
hStack.clipsToBounds = true hStack.clipsToBounds = true
hStack.layer.borderWidth = 0.5 hStack.layer.borderWidth = 0.5
hStack.layer.cornerRadius = 5
hStack.layer.cornerCurve = .continuous hStack.layer.cornerCurve = .continuous
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
updateBorderColor() updateBorderColor()
addSubview(hStack) addSubview(hStack)
hStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(cardTapped)))
hStack.addInteraction(UIContextMenuInteraction(delegate: self))
placeholderImageView = UIImageView(image: UIImage(systemName: "doc.text")) placeholderImageView = UIImageView(image: UIImage(systemName: "doc.text"))
placeholderImageView.translatesAutoresizingMaskIntoConstraints = false placeholderImageView.translatesAutoresizingMaskIntoConstraints = false
placeholderImageView.contentMode = .scaleAspectFit placeholderImageView.contentMode = .scaleAspectFit
placeholderImageView.tintColor = .gray placeholderImageView.tintColor = .gray
placeholderImageView.isHidden = true placeholderImageView.isHidden = true
addSubview(placeholderImageView) addSubview(placeholderImageView)
authorContainerVStack = UIStackView(arrangedSubviews: [
hStack,
])
authorContainerVStack.translatesAutoresizingMaskIntoConstraints = false
authorContainerVStack.axis = .vertical
authorContainerVStack.alignment = .leading
authorContainerVStack.spacing = 4
addSubview(authorContainerVStack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
imageView.heightAnchor.constraint(equalTo: heightAnchor), imageView.heightAnchor.constraint(equalToConstant: Self.cardHeight),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor), imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
vStack.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor, constant: -8), vStack.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor, constant: -8),
@ -130,31 +135,40 @@ class StatusCardView: UIView {
leadingSpacer.widthAnchor.constraint(equalToConstant: 4), leadingSpacer.widthAnchor.constraint(equalToConstant: 4),
trailingSpacer.widthAnchor.constraint(equalToConstant: 4), trailingSpacer.widthAnchor.constraint(equalToConstant: 4),
hStack.leadingAnchor.constraint(equalTo: leadingAnchor), hStack.heightAnchor.constraint(equalToConstant: Self.cardHeight),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor), hStack.widthAnchor.constraint(equalTo: widthAnchor),
hStack.topAnchor.constraint(equalTo: topAnchor),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
placeholderImageView.widthAnchor.constraint(equalToConstant: 30), placeholderImageView.widthAnchor.constraint(equalToConstant: 30),
placeholderImageView.heightAnchor.constraint(equalToConstant: 30), placeholderImageView.heightAnchor.constraint(equalToConstant: 30),
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), 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 // Unneeded on visionOS because there is no light/dark mode
#if !os(visionOS) #if !os(visionOS)
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection) super.traitCollectionDidChange(previousTraitCollection)
updateBorderColor() if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
updateBorderColor()
}
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
updateContentSize()
}
} }
#endif #endif
override func layoutSubviews() {
super.layoutSubviews()
hStack.layer.cornerRadius = 0.1 * bounds.height
}
private func updateBorderColor() { private func updateBorderColor() {
if traitCollection.userInterfaceStyle == .dark { if traitCollection.userInterfaceStyle == .dark {
hStack.layer.borderColor = UIColor.darkGray.cgColor hStack.layer.borderColor = UIColor.darkGray.cgColor
@ -163,8 +177,21 @@ class StatusCardView: UIView {
} }
} }
func updateUI(status: StatusMO) { @objc private func updateContentSize() {
let newData = status.card.map { CardData(card: $0) } 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 { guard self.card != newData else {
return return
} }
@ -176,6 +203,13 @@ class StatusCardView: UIView {
} }
updateUI(card: newData, sensitive: status.sensitive) 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 // This method is internal for use by MockStatusView
@ -184,14 +218,14 @@ class StatusCardView: UIView {
if sensitive { if sensitive {
if let blurhash = card.blurhash { if let blurhash = card.blurhash {
imageView.blurImage = false imageView.blurImage = false
imageView.showOnlyBlurHash(blurhash, for: URL(image)!) imageView.showOnlyBlurHash(blurhash, for: image)
} else { } else {
// if we don't have a blurhash, load the image and show it behind a blur // if we don't have a blurhash, load the image and show it behind a blur
imageView.blurImage = true imageView.blurImage = true
imageView.update(for: URL(image), blurhash: nil) imageView.update(for: image, blurhash: nil)
} }
} else { } else {
imageView.update(for: URL(image), blurhash: card.blurhash) imageView.update(for: image, blurhash: card.blurhash)
} }
imageView.isHidden = false imageView.isHidden = false
leadingSpacer.isHidden = true leadingSpacer.isHidden = true
@ -208,66 +242,124 @@ class StatusCardView: UIView {
let converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self) let converter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLConverter.Callbacks.self)
let description = converter.convert(html: card.description) let description = converter.convert(html: card.description)
descriptionLabel.text = description descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty descriptionLabel.isHidden = description.isEmpty || traitCollection.preferredContentSizeCategory > .extraExtraExtraLarge
if let host = card.url.host { if let host = card.url.host {
domainLabel.text = host.serialized domainLabel.text = host
domainLabel.isHidden = false domainLabel.isHidden = false
} else { } else {
domainLabel.isHidden = true domainLabel.isHidden = true
} }
}
let titleHeight = titleLabel.isHidden ? 0 : titleLabel.sizeThatFits(CGSize(width: titleLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height private func createCardAuthorView(account: AccountMO) {
let descriptionHeight = descriptionLabel.isHidden ? 0 : descriptionLabel.sizeThatFits(CGSize(width: descriptionLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height if let authorHStack {
let domainLabel = domainLabel.isHidden ? 0 : domainLabel.sizeThatFits(CGSize(width: domainLabel.bounds.width, height: UIView.layoutFittingCompressedSize.height)).height authorHStack.isHidden = false
if titleHeight + descriptionHeight + domainLabel > vStack.bounds.height { } else {
descriptionLabel.isHidden = true 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)))
}
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)
} }
} }
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { @objc private func cardTapped() {
hStack.backgroundColor = StatusCardView.activeBackgroundColor if let card, let navigationDelegate {
setNeedsDisplay() navigationDelegate.selected(url: card.url)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
setNeedsDisplay()
if let card = card, let delegate = navigationDelegate {
delegate.selected(url: URL(card.url)!)
} }
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { @objc private func authorTapped() {
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor if let card,
setNeedsDisplay() let authorID = card.authorID,
let navigationDelegate {
navigationDelegate.selected(account: authorID)
}
} }
struct CardData: Equatable { struct CardData: Equatable {
let url: WebURL let url: URL
let image: WebURL? let image: URL?
let title: String let title: String
let description: String let description: String
let blurhash: String? let blurhash: String?
let authorID: String?
init(card: Card) { init(card: Card) {
self.url = card.url self.url = URL(card.url)!
self.image = card.image self.image = card.image.flatMap { URL($0) }
self.title = card.title self.title = card.title
self.description = card.description self.description = card.description
self.blurhash = card.blurhash 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.url = url
self.image = image self.image = image
self.title = title self.title = title
self.description = description self.description = description
self.blurhash = blurhash self.blurhash = blurhash
self.authorID = nil
} }
} }
@ -277,7 +369,7 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let card = card else { return nil } 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? { 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 { private class StatusCardImageView: CachedImageView {
@Lazy private var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark)) @Lazy private var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
var blurImage = false { var blurImage = false {

View File

@ -102,7 +102,7 @@ extension StatusCollectionViewCell {
pollView.delegate = delegate pollView.delegate = delegate
pollView.updateUI(status: status, poll: status.poll) pollView.updateUI(status: status, poll: status.poll)
if Preferences.shared.showLinkPreviews { if Preferences.shared.showLinkPreviews {
cardView.updateUI(status: status) cardView.updateUI(status: status, persistentContainer: mastodonController.persistentContainer)
cardView.isHidden = status.card == nil cardView.isHidden = status.card == nil
cardView.navigationDelegate = delegate cardView.navigationDelegate = delegate
cardView.actionProvider = delegate cardView.actionProvider = delegate

View File

@ -9,9 +9,6 @@
import UIKit import UIKit
class StatusContentContainer: UIView { class StatusContentContainer: UIView {
// TODO: this is a weird place for this
static var cardViewHeight: CGFloat { 90 }
private var arrangedSubviews: [any StatusContentView] private var arrangedSubviews: [any StatusContentView]
private var isHiddenObservations: [NSKeyValueObservation] = [] private var isHiddenObservations: [NSKeyValueObservation] = []
@ -206,12 +203,6 @@ extension ContentTextView: StatusContentView {
} }
} }
extension StatusCardView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
StatusContentContainer.cardViewHeight
}
}
extension AttachmentsContainerView: StatusContentView { extension AttachmentsContainerView: StatusContentView {
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
effectiveWidth / aspectRatio effectiveWidth / aspectRatio

View File

@ -196,9 +196,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.emojiFont = TimelineStatusCollectionViewCell.contentFont $0.emojiFont = TimelineStatusCollectionViewCell.contentFont
} }
let cardView = StatusCardView().configure { let cardView = StatusCardView()
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
let attachmentsView = AttachmentsContainerView() let attachmentsView = AttachmentsContainerView()