parent
8021868599
commit
964cef0ff5
@ -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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -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 */,
|
||||
|
68
Tusker/CoreData/StatusCardMO.swift
Normal file
68
Tusker/CoreData/StatusCardMO.swift
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<?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">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<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="url" attributeType="URI"/>
|
||||
<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="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"/>
|
||||
@ -125,6 +126,7 @@
|
||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||
<attribute name="visibilityString" attributeType="String"/>
|
||||
<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="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"/>
|
||||
@ -134,6 +136,16 @@
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</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">
|
||||
<attribute name="accountID" optional="YES" attributeType="String"/>
|
||||
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
|
||||
@ -162,5 +174,6 @@
|
||||
<memberEntity name="List"/>
|
||||
<memberEntity name="Account"/>
|
||||
<memberEntity name="ActiveInstance"/>
|
||||
<memberEntity name="StatusCard"/>
|
||||
</configuration>
|
||||
</model>
|
@ -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
|
||||
|
@ -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"
|
||||
)
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = StatusCardView.activeBackgroundColor
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
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)!)
|
||||
@objc private func authorTapped() {
|
||||
if let card,
|
||||
let authorID = card.authorID,
|
||||
let navigationDelegate {
|
||||
navigationDelegate.selected(account: authorID)
|
||||
}
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, 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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user