Show link cards on statuses

This commit is contained in:
Shadowfacts 2020-10-25 16:05:28 -04:00
parent 80b3585b71
commit 39b244384b
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
8 changed files with 282 additions and 13 deletions

View File

@ -8,7 +8,7 @@
import Foundation
public class Card: Decodable {
public class Card: Codable {
public let url: URL
public let title: String
public let description: String
@ -21,6 +21,7 @@ public class Card: Decodable {
public let html: String?
public let width: Int?
public let height: Int?
public let blurhash: String?
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
@ -29,14 +30,26 @@ public class Card: Decodable {
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.kind = try container.decode(Kind.self, forKey: .kind)
self.image = try? container.decode(URL.self, forKey: .image)
self.authorName = try? container.decode(String.self, forKey: .authorName)
self.authorURL = try? container.decode(URL.self, forKey: .authorURL)
self.providerName = try? container.decode(String.self, forKey: .providerName)
self.providerURL = try? container.decode(URL.self, forKey: .providerURL)
self.html = try? container.decode(String.self, forKey: .html)
self.width = try? container.decode(Int.self, forKey: .width)
self.height = try? container.decode(Int.self, forKey: .height)
self.image = try? container.decodeIfPresent(URL.self, forKey: .image)
self.authorName = try? container.decodeIfPresent(String.self, forKey: .authorName)
self.authorURL = try? container.decodeIfPresent(URL.self, forKey: .authorURL)
self.providerName = try? container.decodeIfPresent(String.self, forKey: .providerName)
self.providerURL = try? container.decodeIfPresent(URL.self, forKey: .providerURL)
self.html = try? container.decodeIfPresent(String.self, forKey: .html)
self.width = try? container.decodeIfPresent(Int.self, forKey: .width)
self.height = try? container.decodeIfPresent(Int.self, forKey: .height)
self.blurhash = try? container.decodeIfPresent(String.self, forKey: .blurhash)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(url, forKey: .url)
try container.encode(title, forKey: .title)
try container.encode(description, forKey: .description)
try container.encode(kind, forKey: .kind)
try container.encode(image, forKey: .image)
try container.encode(blurhash, forKey: .blurhash)
}
private enum CodingKeys: String, CodingKey {
@ -52,14 +65,14 @@ public class Card: Decodable {
case html
case width
case height
case blurhash
}
}
extension Card {
public enum Kind: String, Decodable {
public enum Kind: String, Codable {
case link
case photo
case video
case rich
}
}

View File

@ -244,6 +244,7 @@
D6C143DA253510F4007DC240 /* ComposeContentWarningTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */; };
D6C143E025354E34007DC240 /* EmojiPickerCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */; };
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -579,6 +580,7 @@
D6C143D9253510F4007DC240 /* ComposeContentWarningTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeContentWarningTextField.swift; sourceTree = "<group>"; };
D6C143DF25354E34007DC240 /* EmojiPickerCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerCollectionViewController.swift; sourceTree = "<group>"; };
D6C143FB25354FD0007DC240 /* EmojiCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiCollectionViewCell.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -1061,6 +1063,7 @@
D6BED173212667E900F02DA0 /* TimelineStatusTableViewCell.swift */,
D663625C2135C74800C9CBA2 /* ConversationMainStatusTableViewCell.xib */,
D663625E2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift */,
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */,
);
path = Status;
sourceTree = "<group>";
@ -1945,6 +1948,7 @@
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,

View File

@ -21,6 +21,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var applicationName: String?
@NSManaged private var attachmentsData: Data?
@NSManaged private var bookmarkedInternal: Bool
@NSManaged public var cardData: Data?
@NSManaged public var content: String
@NSManaged public var createdAt: Date
@NSManaged private var emojisData: Data?
@ -56,6 +57,9 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@LazilyDecoding(arrayFrom: \StatusMO.mentionsData)
public var mentions: [Mention]
@LazilyDecoding(from: \StatusMO.cardData, fallback: nil)
public var card: Card?
public var pinned: Bool? { pinnedInternal }
public var bookmarked: Bool? { bookmarkedInternal }
@ -105,6 +109,7 @@ 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.emojis = status.emojis

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17507" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17510.1" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/>
<attribute name="avatar" attributeType="URI"/>
@ -44,6 +44,7 @@
<attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/>
<attribute name="bookmarkedInternal" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="cardData" optional="YES" attributeType="Binary"/>
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="emojisData" attributeType="Binary" customClassName="[Data]"/>
@ -74,7 +75,7 @@
</entity>
<elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="418"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="433"/>
</elements>
</model>

View File

@ -32,6 +32,7 @@ class BaseStatusTableViewCell: UITableViewCell {
@IBOutlet weak var contentWarningLabel: EmojiLabel!
@IBOutlet weak var collapseButton: UIButton!
@IBOutlet weak var contentTextView: StatusContentTextView!
@IBOutlet weak var cardView: StatusCardView!
@IBOutlet weak var attachmentsView: AttachmentsContainerView!
@IBOutlet weak var replyButton: UIButton!
@IBOutlet weak var favoriteButton: UIButton!
@ -143,6 +144,10 @@ class BaseStatusTableViewCell: UITableViewCell {
updateUI(account: account)
updateUIForPreferences(account: account, status: status)
cardView.card = status.card
cardView.isHidden = status.card == nil
cardView.navigationDelegate = navigationDelegate
attachmentsView.updateUI(status: status)
attachmentsView.isAccessibilityElement = status.attachments.count > 0
attachmentsView.accessibilityLabel = String(format: NSLocalizedString("%d attachments", comment: "status attachments count accessibility label"), status.attachments.count)
@ -265,6 +270,7 @@ class BaseStatusTableViewCell: UITableViewCell {
self.collapsed = collapsed
contentTextView.isHidden = collapsed
cardView.isHidden = cardView.card == nil || collapsed
attachmentsView.isHidden = attachmentsView.attachments.count == 0 || collapsed
let buttonImage = UIImage(systemName: collapsed ? "chevron.down" : "chevron.up")!

View File

@ -96,6 +96,13 @@
<fontDescription key="fontDescription" type="system" pointSize="20"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="QqC-GR-TLC" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="Tdo-Hv-ITE"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="IF9-9U-Gk0" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="176" width="343" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
@ -205,6 +212,7 @@
</stackView>
</subviews>
<constraints>
<constraint firstItem="QqC-GR-TLC" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="2WL-jD-I09"/>
<constraint firstItem="Cnd-Fj-B7l" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="2hS-RG-81T"/>
<constraint firstItem="z0g-HN-gS0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="4TF-2Z-mdf"/>
<constraint firstItem="IF9-9U-Gk0" firstAttribute="width" secondItem="GuG-Qd-B8I" secondAttribute="width" id="8A8-wi-7sg"/>
@ -227,6 +235,7 @@
<connections>
<outlet property="attachmentsView" destination="IF9-9U-Gk0" id="Oxw-sJ-MJE"/>
<outlet property="avatarImageView" destination="mB9-HO-1vf" id="0R0-rt-Osh"/>
<outlet property="cardView" destination="QqC-GR-TLC" id="CWR-fH-IfE"/>
<outlet property="collapseButton" destination="8r8-O8-Agh" id="0es-Hi-bpt"/>
<outlet property="contentTextView" destination="z0g-HN-gS0" id="atk-1f-83e"/>
<outlet property="contentWarningLabel" destination="cwQ-mR-L1b" id="5sm-PC-FIN"/>

View File

@ -0,0 +1,223 @@
//
// StatusCardView.swift
// Tusker
//
// Created by Shadowfacts on 10/25/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SafariServices
class StatusCardView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate?
var card: Card? {
didSet {
if let card = card {
self.updateUI(card: card)
}
}
}
private let activeBackgroundColor = UIColor.secondarySystemFill
private let inactiveBackgroundColor = UIColor.secondarySystemBackground
private var imageRequest: ImageCache.Request?
private var titleLabel: UILabel!
private var descriptionLabel: UILabel!
private var imageView: UIImageView!
private var placeholderImageView: UIImageView!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
self.clipsToBounds = true
self.layer.cornerRadius = 6.5
self.layer.borderWidth = 1
self.layer.borderColor = UIColor.lightGray.cgColor
self.backgroundColor = inactiveBackgroundColor
self.addInteraction(UIContextMenuInteraction(delegate: self))
titleLabel = UILabel()
titleLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!, size: 0)
titleLabel.numberOfLines = 2
descriptionLabel = UILabel()
descriptionLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .caption1), size: 0)
descriptionLabel.numberOfLines = 2
descriptionLabel.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
let vStack = UIStackView(arrangedSubviews: [
titleLabel,
descriptionLabel
])
vStack.axis = .vertical
vStack.alignment = .leading
vStack.distribution = .fill
vStack.spacing = 0
imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
let hStack = UIStackView(arrangedSubviews: [
imageView,
vStack
])
hStack.translatesAutoresizingMaskIntoConstraints = false
hStack.axis = .horizontal
hStack.alignment = .center
hStack.distribution = .fill
hStack.spacing = 4
addSubview(hStack)
placeholderImageView = UIImageView(image: UIImage(systemName: "doc.text"))
placeholderImageView.translatesAutoresizingMaskIntoConstraints = false
placeholderImageView.contentMode = .scaleAspectFit
placeholderImageView.tintColor = .gray
addSubview(placeholderImageView)
NSLayoutConstraint.activate([
imageView.heightAnchor.constraint(equalTo: heightAnchor),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -4),
hStack.topAnchor.constraint(equalTo: topAnchor),
hStack.bottomAnchor.constraint(equalTo: bottomAnchor),
placeholderImageView.widthAnchor.constraint(equalToConstant: 30),
placeholderImageView.heightAnchor.constraint(equalToConstant: 30),
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
])
}
private func updateUI(card: Card) {
self.imageView.image = nil
if let image = card.image {
placeholderImageView.isHidden = true
imageRequest = ImageCache.attachments.get(image, completion: { (data) in
guard let data = data, let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
self.imageView.image = image
}
})
if imageRequest != nil {
loadBlurHash()
}
} else {
placeholderImageView.isHidden = false
}
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title
titleLabel.isHidden = title.isEmpty
let description = card.description.trimmingCharacters(in: .whitespacesAndNewlines)
descriptionLabel.text = description
descriptionLabel.isHidden = description.isEmpty
}
private func loadBlurHash() {
guard let card = card, let hash = card.blurhash else { return }
let imageViewSize = self.imageView.bounds.size
// todo: merge this code with AttachmentView, use a single DispatchQueue
DispatchQueue.global(qos: .default).async { [weak self] in
guard let self = self else { return }
let size: CGSize
if let width = card.width, let height = card.height {
size = CGSize(width: width, height: height)
} else {
size = imageViewSize
}
guard let preview = UIImage(blurHash: hash, size: size) else {
return
}
DispatchQueue.main.async { [weak self] in
guard let self = self,
self.card?.url == card.url,
self.imageView.image == nil else { return }
self.imageView.image = preview
}
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = activeBackgroundColor
setNeedsDisplay()
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = inactiveBackgroundColor
setNeedsDisplay()
if let card = card, let delegate = navigationDelegate {
delegate.selected(url: card.url)
}
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
backgroundColor = inactiveBackgroundColor
setNeedsDisplay()
}
}
extension StatusCardView: MenuPreviewProvider {
}
extension StatusCardView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let card = card else { return nil }
return UIContextMenuConfiguration(identifier: nil) {
return SFSafariViewController(url: card.url)
} actionProvider: { (_) in
let actions = self.actionsForURL(card.url, sourceView: self)
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController,
let delegate = navigationDelegate {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: delegate)
} else {
delegate.show(viewController)
}
}
}
}
}

View File

@ -114,6 +114,13 @@
<fontDescription key="fontDescription" type="system" pointSize="16"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LKo-VB-XWl" customClass="StatusCardView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="65" id="khY-jm-CPn"/>
</constraints>
</view>
<view hidden="YES" contentMode="scaleToFill" verticalCompressionResistancePriority="1" translatesAutoresizingMaskIntoConstraints="NO" id="nbq-yr-2mA" customClass="AttachmentsContainerView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="169.5" width="277" height="0.0"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
@ -238,6 +245,7 @@
<connections>
<outlet property="attachmentsView" destination="nbq-yr-2mA" id="SVm-zl-mPb"/>
<outlet property="avatarImageView" destination="QMP-j2-HLn" id="xfS-v8-Gzu"/>
<outlet property="cardView" destination="LKo-VB-XWl" id="6X5-8P-Ata"/>
<outlet property="collapseButton" destination="O0E-Vf-XYR" id="nWd-gg-st8"/>
<outlet property="contentTextView" destination="waJ-f5-LKv" id="hrR-Zg-gLY"/>
<outlet property="contentWarningLabel" destination="inI-Og-YiU" id="C7a-eK-qcx"/>