From 05c895db889021c5a656fe659db7ed7eb08155c0 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Mon, 27 Aug 2018 21:27:34 -0400 Subject: [PATCH] Profile pages --- Tusker.xcodeproj/project.pbxproj | 34 +- Tusker/AvatarCache.swift | 12 +- Tusker/Storyboards/Profile.storyboard | 37 +++ .../ProfileTableViewController.swift | 132 ++++++++ .../TimelineTableViewController.swift | 27 +- Tusker/Views/HTMLContentLabel.swift | 302 ++++++++++++++++++ Tusker/Views/ProfileHeaderTableViewCell.swift | 96 ++++++ Tusker/Views/ProfileHeaderTableViewCell.xib | 93 ++++++ Tusker/Views/StatusContentLabel.swift | 294 +---------------- Tusker/Views/StatusTableViewCell.swift | 26 +- Tusker/Views/StatusTableViewCell.xib | 3 + ...ntroller+StatusTableViewCellDelegate.swift | 36 +++ 12 files changed, 765 insertions(+), 327 deletions(-) create mode 100644 Tusker/Storyboards/Profile.storyboard create mode 100644 Tusker/View Controllers/ProfileTableViewController.swift create mode 100644 Tusker/Views/HTMLContentLabel.swift create mode 100644 Tusker/Views/ProfileHeaderTableViewCell.swift create mode 100644 Tusker/Views/ProfileHeaderTableViewCell.xib create mode 100644 Tusker/Views/UIViewController+StatusTableViewCellDelegate.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 62fd8ec3..a677aaa5 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -10,11 +10,17 @@ 04DACE8A212CA6B7009840C4 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE89212CA6B7009840C4 /* Timeline.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */; }; - D64A0CD32132153900640E3B /* StatusContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A0CD22132153900640E3B /* StatusContentLabel.swift */; }; + D64A0CD32132153900640E3B /* HTMLContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A0CD22132153900640E3B /* HTMLContentLabel.swift */; }; D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D667E5E12134937B0057A976 /* StatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* StatusTableViewCell.xib */; }; + D667E5E3213499F70057A976 /* Profile.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E2213499F70057A976 /* Profile.storyboard */; }; + D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */; }; + D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */; }; + D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */; }; + D667E5EF2134C39F0057A976 /* StatusContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5EE2134C39F0057A976 /* StatusContentLabel.swift */; }; + D667E5F12134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D667E5F02134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift */; }; D6BED16F212663DA00F02DA0 /* SwiftSoup.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; }; D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */; }; @@ -67,11 +73,17 @@ 04DACE89212CA6B7009840C4 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = ""; }; 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = ""; }; - D64A0CD22132153900640E3B /* StatusContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentLabel.swift; sourceTree = ""; }; + D64A0CD22132153900640E3B /* HTMLContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLContentLabel.swift; sourceTree = ""; }; D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = ""; }; D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = ""; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D667E5E02134937B0057A976 /* StatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusTableViewCell.xib; sourceTree = ""; }; + D667E5E2213499F70057A976 /* Profile.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Profile.storyboard; sourceTree = ""; }; + D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTableViewController.swift; sourceTree = ""; }; + D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderTableViewCell.xib; sourceTree = ""; }; + D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderTableViewCell.swift; sourceTree = ""; }; + D667E5EE2134C39F0057A976 /* StatusContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentLabel.swift; sourceTree = ""; }; + D667E5F02134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+StatusTableViewCellDelegate.swift"; sourceTree = ""; }; D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = SwiftSoup.framework; sourceTree = BUILT_PRODUCTS_DIR; }; D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = ""; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -122,9 +134,13 @@ D6BED1722126661300F02DA0 /* Views */ = { isa = PBXGroup; children = ( - D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */, - D64A0CD22132153900640E3B /* StatusContentLabel.swift */, + D64A0CD22132153900640E3B /* HTMLContentLabel.swift */, + D667E5EE2134C39F0057A976 /* StatusContentLabel.swift */, D667E5E02134937B0057A976 /* StatusTableViewCell.xib */, + D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */, + D667E5F02134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift */, + D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */, + D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */, ); path = Views; sourceTree = ""; @@ -193,6 +209,7 @@ D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */, D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, + D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -203,6 +220,7 @@ D6D4DDD3212518A000E1C4BB /* Main.storyboard */, D6F953ED21251A0700CF0F2B /* Timeline.storyboard */, D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */, + D667E5E2213499F70057A976 /* Profile.storyboard */, ); path = Storyboards; sourceTree = ""; @@ -320,11 +338,13 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */, D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */, D6F953EE21251A0700CF0F2B /* Timeline.storyboard in Resources */, D6D4DDD5212518A000E1C4BB /* Main.storyboard in Resources */, + D667E5E3213499F70057A976 /* Profile.storyboard in Resources */, D667E5E12134937B0057A976 /* StatusTableViewCell.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -356,9 +376,13 @@ 04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */, D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, + D667E5EF2134C39F0057A976 /* StatusContentLabel.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, - D64A0CD32132153900640E3B /* StatusContentLabel.swift in Sources */, + D667E5EB21349EF80057A976 /* ProfileHeaderTableViewCell.swift in Sources */, + D64A0CD32132153900640E3B /* HTMLContentLabel.swift in Sources */, + D667E5F12134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift in Sources */, + D667E5E721349D4C0057A976 /* ProfileTableViewController.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Tusker/AvatarCache.swift b/Tusker/AvatarCache.swift index 72859838..cbc8a58c 100644 --- a/Tusker/AvatarCache.swift +++ b/Tusker/AvatarCache.swift @@ -35,18 +35,18 @@ class AvatarCache { guard error == nil, let data = data, let image = UIImage(data: data) else { - let callbacks = self.requestCallbacks.removeValue(forKey: url)! - for callback in callbacks { + let callbacks = self.requestCallbacks.removeValue(forKey: url) + callbacks?.forEach({ callback in // todo: default avatar for failed requests callback(nil) - } + }) return } - let callbacks = self.requestCallbacks.removeValue(forKey: url)! - for callback in callbacks { + let callbacks = self.requestCallbacks.removeValue(forKey: url) + callbacks?.forEach({ callback in callback(image) - } + }) self.cache.setObject(image, forKey: key) } task.resume() diff --git a/Tusker/Storyboards/Profile.storyboard b/Tusker/Storyboards/Profile.storyboard new file mode 100644 index 00000000..559e2f0b --- /dev/null +++ b/Tusker/Storyboards/Profile.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/View Controllers/ProfileTableViewController.swift b/Tusker/View Controllers/ProfileTableViewController.swift new file mode 100644 index 00000000..1938577d --- /dev/null +++ b/Tusker/View Controllers/ProfileTableViewController.swift @@ -0,0 +1,132 @@ +// +// ProfileTableViewController.swift +// Tusker +// +// Created by Shadowfacts on 8/27/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import MastodonKit +import SafariServices + +class ProfileTableViewController: UITableViewController { + + static func create(for account: Account) -> UIViewController { + guard let profileController = UIStoryboard(name: "Profile", bundle: nil).instantiateInitialViewController() as? ProfileTableViewController else { fatalError() } + profileController.account = account + return profileController + } + + var account: Account! + + var statuses: [Status] = [] { + didSet { + DispatchQueue.main.async { + self.tableView.reloadData() + } + } + } + + var newer: RequestRange? + var older: RequestRange? + + func request(for range: RequestRange? = .default) -> Request<[Status]> { + let range = range ?? .default + return Accounts.statuses(id: account.id, mediaOnly: false, pinnedOnly: false, excludeReplies: true, range: range) + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 140 + + tableView.register(UINib(nibName: "StatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell") + tableView.register(UINib(nibName: "ProfileHeaderTableViewCell", bundle: nil), forCellReuseIdentifier: "headerCell") + + navigationItem.title = account.displayName + + MastodonController.shared.client.run(request()) { result in + guard case let .success(statuses, pagination) = result else { fatalError() } + self.statuses = statuses + self.newer = pagination?.previous + self.older = pagination?.next + } + } + + /* + // MARK: - Navigation + + // In a storyboard-based application, you will often want to do a little preparation before navigation + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + // Get the new view controller using segue.destination. + // Pass the selected object to the new view controller. + } + */ + + // MARK: - Table view data source + + override func numberOfSections(in tableView: UITableView) -> Int { + return 2 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch section { + case 0: + return 1 + case 1: + return statuses.count + default: + return 0 + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch indexPath.section { + case 0: + let cell = tableView.dequeueReusableCell(withIdentifier: "headerCell", for: indexPath) as! ProfileHeaderTableViewCell + cell.selectionStyle = .none + cell.updateUI(for: account) + cell.delegate = self + return cell + case 1: + let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! StatusTableViewCell + let status = statuses[indexPath.row] + cell.updateUI(for: status) + cell.delegate = self + return cell + default: + fatalError("Invalid section \(indexPath.section) for profile VC") + } + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + if indexPath.section == 1 && indexPath.row == statuses.count - 1 { + guard let older = older else { return } + + MastodonController.shared.client.run(request(for: older)) { result in + guard case let .success(newStatuses, pagination) = result else { fatalError() } + self.older = pagination?.next + self.statuses.append(contentsOf: newStatuses) + } + } + } + + @IBAction func refreshStatuses(_ sender: Any) { + guard let newer = newer else { return } + + MastodonController.shared.client.run(request(for: newer)) { result in + guard case let .success(newStatuses, pagination) = result else { fatalError() } + self.newer = pagination?.previous + self.statuses.insert(contentsOf: newStatuses, at: 0) + DispatchQueue.main.async { + self.refreshControl?.endRefreshing() + } + } + } +} + +extension ProfileTableViewController: ProfileHeaderTableViewCellDelegate { + +} diff --git a/Tusker/View Controllers/TimelineTableViewController.swift b/Tusker/View Controllers/TimelineTableViewController.swift index ce8c1cbd..7b6a960f 100644 --- a/Tusker/View Controllers/TimelineTableViewController.swift +++ b/Tusker/View Controllers/TimelineTableViewController.swift @@ -8,7 +8,6 @@ import UIKit import MastodonKit -import SwiftSoup import SafariServices class TimelineTableViewController: UITableViewController { @@ -52,18 +51,13 @@ class TimelineTableViewController: UITableViewController { tableView.estimatedRowHeight = 140 tableView.register(UINib(nibName: "StatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell") - } - - override func viewWillAppear(_ animated: Bool) { + guard MastodonController.shared.client?.accessToken != nil else { return } MastodonController.shared.client.run(timeline.request()) { result in guard case let .success(statuses, pagination) = result else { fatalError() } self.statuses = statuses self.newer = pagination?.previous self.older = pagination?.next - DispatchQueue.main.async { - self.tableView.reloadData() - } } } @@ -89,9 +83,7 @@ class TimelineTableViewController: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? StatusTableViewCell else { - fatalError() - } + let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! StatusTableViewCell let status = statuses[indexPath.row] @@ -131,18 +123,3 @@ class TimelineTableViewController: UITableViewController { } } - -extension TimelineTableViewController: StatusTableViewCellDelegate { - func selected(mention: Mention) { - - } - - func selected(tag: MastodonKit.Tag) { - - } - - func selected(url: URL) { - let vc = SFSafariViewController(url: url) - present(vc, animated: true) - } -} diff --git a/Tusker/Views/HTMLContentLabel.swift b/Tusker/Views/HTMLContentLabel.swift new file mode 100644 index 00000000..7e93adcb --- /dev/null +++ b/Tusker/Views/HTMLContentLabel.swift @@ -0,0 +1,302 @@ +// +// StatusContentLabel.swift +// Tusker +// +// Created by Shadowfacts on 8/25/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import MastodonKit +import SwiftSoup + +protocol HTMLContentLabelDelegate { + + func selected(mention: Mention) + + func selected(tag: MastodonKit.Tag) + + func selected(url: URL) + +} + +class HTMLContentLabel: UILabel { + + var delegate: HTMLContentLabelDelegate? + + override var text: String? { + didSet { + parseHTML() + } + } + + override var attributedText: NSAttributedString? { + didSet { + updateTextStorage() + } + } + + override var numberOfLines: Int { + didSet { + textContainer.maximumNumberOfLines = numberOfLines + } + } + + override var lineBreakMode: NSLineBreakMode { + didSet { + textContainer.lineBreakMode = lineBreakMode + } + } + +// var status: Status! { +// didSet { +// text = status.content +// } +// } + + private var _customizing = true + + private lazy var textStorage = NSTextStorage() + private lazy var layoutManager = NSLayoutManager() + private lazy var textContainer = NSTextContainer() + + private var selectedLink: (range: NSRange, url: URL)? + + private var links: [NSRange: URL] = [:] + + override init(frame: CGRect) { + super.init(frame: frame) + _customizing = false + setupLabel() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + _customizing = false + setupLabel() + } + + private func setupLabel() { + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + textContainer.lineFragmentPadding = 0 + textContainer.lineBreakMode = lineBreakMode + textContainer.maximumNumberOfLines = numberOfLines + isUserInteractionEnabled = true + } + + override func awakeFromNib() { + super.awakeFromNib() + updateTextStorage() + } + + override var intrinsicContentSize: CGSize { + let superSize = super.intrinsicContentSize + textContainer.size = CGSize(width: superSize.width, height: .greatestFiniteMagnitude) + let size = layoutManager.usedRect(for: textContainer) + return CGSize(width: ceil(size.width), height: ceil(size.height)) + } + + override func drawText(in rect: CGRect) { + let range = NSRange(location: 0, length: textStorage.length) + + textContainer.size = rect.size + let origin = rect.origin + + layoutManager.drawBackground(forGlyphRange: range, at: origin) + layoutManager.drawGlyphs(forGlyphRange: range, at: origin) + } + + // MARK: - HTML parsing + + private func parseHTML() { + if _customizing { return } + guard let text = text else { return } + + let doc = try! SwiftSoup.parse(text) + let body = doc.body()! + + let (attributedText, links) = attributedTextForHTMLNode(body) + self.links = links + let mutAttrString = NSMutableAttributedString(attributedString: attributedText) + mutAttrString.addAttribute(.font, value: font, range: NSRange(location: 0, length: mutAttrString.length)) + self.attributedText = mutAttrString + } + + private func attributedTextForHTMLNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) { + switch node { + case let node as TextNode: + return (NSAttributedString(string: node.text()), [:]) + case let node as Element: + var links = [NSRange: URL]() + let attributed = NSMutableAttributedString() + node.getChildNodes().forEach { child in + let (text, childLinks) = attributedTextForHTMLNode(child) + childLinks.forEach { range, url in + let newRange = NSRange(location: range.location + attributed.length, length: range.length) + links[newRange] = url + } + attributed.append(text) + } + + switch node.tagName() { + case "br": + attributed.append(NSAttributedString(string: "\n")) + case "a": + if let link = try? node.attr("href"), + let url = URL(string: link) { + let linkRange = NSRange(location: 0, length: attributed.length) + let linkAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.blue + ] + attributed.setAttributes(linkAttributes, range: linkRange) + + links[linkRange] = url + } + default: + break + } + + return (attributed, links) + default: + fatalError("Unexpected node type: \(type(of: node))") + } + } + + // MARK: - Text Storage + + private func updateTextStorage() { + if _customizing { return } + guard let attributedText = attributedText, + attributedText.length > 0 else { + links = [:] + textStorage.setAttributedString(NSAttributedString()) + setNeedsDisplay() + return + } + + textStorage.setAttributedString(attributedText) + _customizing = true + text = attributedText.string + _customizing = false + setNeedsDisplay() + } + + // MARK: - Interaction + + func getMention(for url: URL, text: String) -> Mention? { + // todo: figure out how to get account IDs + return nil + } + + func getTag(for url: URL, text: String) -> MastodonKit.Tag? { + if text.starts(with: "#") { + let tag = String(text.dropFirst()) + return MastodonKit.Tag(name: tag, url: url.absoluteString) + } else { + return nil + } + } + + private func onTouch(_ touch: UITouch) -> Bool { + let location = touch.location(in: self) + var avoidSuperCall = false + + switch touch.phase { + case .began, .moved: + if let link = link(at: location) { + if link.range.location != selectedLink?.range.location || link.range.length != selectedLink?.range.length { + updateAttributesWhenSelected(false) + selectedLink = link + updateAttributesWhenSelected(true) + } + avoidSuperCall = true + } else { + updateAttributesWhenSelected(false) + selectedLink = nil + } + case .ended: + guard let selectedLink = selectedLink else { return avoidSuperCall } + + let text = String(self.text![Range(selectedLink.range, in: self.text!)!]) + if let delegate = delegate { + if let mention = getMention(for: selectedLink.url, text: text) { + delegate.selected(mention: mention) + } else if let tag = getTag(for: selectedLink.url, text: text) { + delegate.selected(tag: tag) + } else { + delegate.selected(url: selectedLink.url) + } + } + + let when = DispatchTime.now() + Double(Int64(0.25 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) + DispatchQueue.main.asyncAfter(deadline: when) { + self.updateAttributesWhenSelected(false) + self.selectedLink = nil + } + case .cancelled: + updateAttributesWhenSelected(false) + selectedLink = nil + case .stationary: + break + } + + return avoidSuperCall + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + if onTouch(touch) { return } + super.touchesBegan(touches, with: event) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + if onTouch(touch) { return } + super.touchesMoved(touches, with: event) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + if onTouch(touch) { return } + super.touchesCancelled(touches, with: event) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + guard let touch = touches.first else { return } + if onTouch(touch) { return } + super.touchesEnded(touches, with: event) + } + + private func link(at location: CGPoint) -> (range: NSRange, url: URL)? { + guard textStorage.length > 0 else { return nil } + + let boundingRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: textStorage.length), in: textContainer) + guard boundingRect.contains(location) else { + return nil + } + + let index = layoutManager.glyphIndex(for: location, in: textContainer) + + for (range, url) in links { + if index >= range.location && index <= range.location + range.length { + return (range, url) + } + } + + return nil + } + + private func updateAttributesWhenSelected(_ isSelected: Bool) { + guard let selectedLink = selectedLink else { return } + + var attributes = textStorage.attributes(at: 0, effectiveRange: nil) + + attributes[.foregroundColor] = isSelected ? nil : UIColor.blue + + textStorage.addAttributes(attributes, range: selectedLink.range) + + setNeedsDisplay() + } + +} diff --git a/Tusker/Views/ProfileHeaderTableViewCell.swift b/Tusker/Views/ProfileHeaderTableViewCell.swift new file mode 100644 index 00000000..dbcd2e1a --- /dev/null +++ b/Tusker/Views/ProfileHeaderTableViewCell.swift @@ -0,0 +1,96 @@ +// +// ProfileHeaderTableViewCell.swift +// Tusker +// +// Created by Shadowfacts on 8/27/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import MastodonKit + +protocol ProfileHeaderTableViewCellDelegate: StatusTableViewCellDelegate { + +} + +class ProfileHeaderTableViewCell: UITableViewCell { + + var delegate: ProfileHeaderTableViewCellDelegate? + + @IBOutlet weak var displayNameLabel: UILabel! + @IBOutlet weak var usernameLabel: UILabel! + @IBOutlet weak var noteLabel: HTMLContentLabel! + @IBOutlet weak var avatarContainerView: UIView! + @IBOutlet weak var avatarImageView: UIImageView! + @IBOutlet weak var headerImageView: UIImageView! + + var account: Account! + + var avatarURL: URL? + + var headerImageDownloadTask: URLSessionDataTask? + + override func awakeFromNib() { + avatarContainerView.layer.cornerRadius = 12 + avatarContainerView.layer.masksToBounds = true + avatarImageView.layer.cornerRadius = 11.6 + avatarImageView.layer.masksToBounds = true + } + + func updateUI(for account: Account) { + self.account = account + + displayNameLabel.text = account.displayName + usernameLabel.text = "@\(account.acct)" + + avatarImageView.image = nil + if let url = URL(string: account.avatar) { + avatarURL = url + AvatarCache.shared.get(url) { image in + DispatchQueue.main.async { + self.avatarImageView.image = image + self.avatarURL = nil + } + } + } + if let url = URL(string: account.header) { + headerImageDownloadTask = URLSession.shared.dataTask(with: url) { data, response, error in + guard error == nil, + let data = data, + let image = UIImage(data: data) else { return } + DispatchQueue.main.async { + self.headerImageView.image = image + self.headerImageDownloadTask = nil + } + } + headerImageDownloadTask!.resume() + } + + // todo: HTML parsing + noteLabel.text = account.note + noteLabel.delegate = self + } + + override func prepareForReuse() { + if let url = avatarURL { + AvatarCache.shared.cancel(url) + } + } + +} + +extension ProfileHeaderTableViewCell: HTMLContentLabelDelegate { + + func selected(mention: Mention) { + delegate?.selected(mention: mention) + } + + func selected(tag: Tag) { + delegate?.selected(tag: tag) + } + + func selected(url: URL) { + delegate?.selected(url: url) + } + +} diff --git a/Tusker/Views/ProfileHeaderTableViewCell.xib b/Tusker/Views/ProfileHeaderTableViewCell.xib new file mode 100644 index 00000000..47727882 --- /dev/null +++ b/Tusker/Views/ProfileHeaderTableViewCell.xib @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tusker/Views/StatusContentLabel.swift b/Tusker/Views/StatusContentLabel.swift index 90042a4c..6eab953c 100644 --- a/Tusker/Views/StatusContentLabel.swift +++ b/Tusker/Views/StatusContentLabel.swift @@ -2,51 +2,14 @@ // StatusContentLabel.swift // Tusker // -// Created by Shadowfacts on 8/25/18. +// Created by Shadowfacts on 8/27/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import MastodonKit -import SwiftSoup -protocol StatusContentLabelDelegate { - - func selected(mention: Mention) - - func selected(tag: MastodonKit.Tag) - - func selected(url: URL) - -} - -class StatusContentLabel: UILabel { - - var delegate: StatusContentLabelDelegate? - - override var text: String? { - didSet { - parseHTML() - } - } - - override var attributedText: NSAttributedString? { - didSet { - updateTextStorage() - } - } - - override var numberOfLines: Int { - didSet { - textContainer.maximumNumberOfLines = numberOfLines - } - } - - override var lineBreakMode: NSLineBreakMode { - didSet { - textContainer.lineBreakMode = lineBreakMode - } - } +class StatusContentLabel: HTMLContentLabel { var status: Status! { didSet { @@ -54,265 +17,20 @@ class StatusContentLabel: UILabel { } } - private var _customizing = true - - private lazy var textStorage = NSTextStorage() - private lazy var layoutManager = NSLayoutManager() - private lazy var textContainer = NSTextContainer() - - private var selectedLink: (range: NSRange, url: URL)? - - private var links: [NSRange: URL] = [:] - - override init(frame: CGRect) { - super.init(frame: frame) - _customizing = false - setupLabel() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - _customizing = false - setupLabel() - } - - private func setupLabel() { - textStorage.addLayoutManager(layoutManager) - layoutManager.addTextContainer(textContainer) - textContainer.lineFragmentPadding = 0 - textContainer.lineBreakMode = lineBreakMode - textContainer.maximumNumberOfLines = numberOfLines - isUserInteractionEnabled = true - } - - override func awakeFromNib() { - super.awakeFromNib() - updateTextStorage() - } - - override var intrinsicContentSize: CGSize { - let superSize = super.intrinsicContentSize - textContainer.size = CGSize(width: superSize.width, height: .greatestFiniteMagnitude) - let size = layoutManager.usedRect(for: textContainer) - return CGSize(width: ceil(size.width), height: ceil(size.height)) - } - - override func drawText(in rect: CGRect) { - let range = NSRange(location: 0, length: textStorage.length) - - textContainer.size = rect.size - let origin = rect.origin - - layoutManager.drawBackground(forGlyphRange: range, at: origin) - layoutManager.drawGlyphs(forGlyphRange: range, at: origin) - } - - // MARK: - HTML parsing - - private func parseHTML() { - if _customizing { return } - guard let text = text else { return } - - let doc = try! SwiftSoup.parse(text) - let body = doc.body()! - - let (attributedText, links) = attributedTextForHTMLNode(body) - self.links = links - let mutAttrString = NSMutableAttributedString(attributedString: attributedText) - mutAttrString.addAttribute(.font, value: font, range: NSRange(location: 0, length: mutAttrString.length)) - self.attributedText = mutAttrString - } - - private func attributedTextForHTMLNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) { - switch node { - case let node as TextNode: - return (NSAttributedString(string: node.text()), [:]) - case let node as Element: - var links = [NSRange: URL]() - let attributed = NSMutableAttributedString() - node.getChildNodes().forEach { child in - let (text, childLinks) = attributedTextForHTMLNode(child) - childLinks.forEach { range, url in - let newRange = NSRange(location: range.location + attributed.length, length: range.length) - links[newRange] = url - } - attributed.append(text) - } - - switch node.tagName() { - case "br": - attributed.append(NSAttributedString(string: "\n")) - case "a": - if let link = try? node.attr("href"), - let url = URL(string: link) { - let linkRange = NSRange(location: 0, length: attributed.length) - let linkAttributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: UIColor.blue - ] - attributed.setAttributes(linkAttributes, range: linkRange) - - links[linkRange] = url - } - default: - break - } - - return (attributed, links) - default: - fatalError("Unexpected node type: \(type(of: node))") - } - } - - // MARK: - Text Storage - - private func updateTextStorage() { - if _customizing { return } - guard let attributedText = attributedText, - attributedText.length > 0 else { - links = [:] - textStorage.setAttributedString(NSAttributedString()) - setNeedsDisplay() - return - } - - textStorage.setAttributedString(attributedText) - _customizing = true - text = attributedText.string - _customizing = false - setNeedsDisplay() - } - - // MARK: - Interaction - - private func getMention(for url: URL, text: String) -> Mention? { + override func getMention(for url: URL, text: String) -> Mention? { return status.mentions.first(where: { mention -> Bool in (text.dropFirst() == mention.username || text == mention.username) && url.host == URL(string: mention.url)!.host - }) + }) ?? super.getMention(for: url, text: text) } - private func getTag(for url: URL, text: String) -> MastodonKit.Tag? { + override func getTag(for url: URL, text: String) -> MastodonKit.Tag? { if let tag = status.tags.first(where: { tag -> Bool in tag.url == url.absoluteString }) { return tag - } else if text.starts(with: "#") { - let tag = String(text.dropFirst()) - return MastodonKit.Tag(name: tag, url: url.absoluteString) } else { - return nil + return super.getTag(for: url, text: text) } } - private func getEntity(for url: URL, text: String) -> Any { - if let mention = getMention(for: url, text: text) { - return mention - } else if let tag = getTag(for: url, text: text) { - return tag - } else { - return url - } - } - - private func onTouch(_ touch: UITouch) -> Bool { - let location = touch.location(in: self) - var avoidSuperCall = false - - switch touch.phase { - case .began, .moved: - if let link = link(at: location) { - if link.range.location != selectedLink?.range.location || link.range.length != selectedLink?.range.length { - updateAttributesWhenSelected(false) - selectedLink = link - updateAttributesWhenSelected(true) - } - avoidSuperCall = true - } else { - updateAttributesWhenSelected(false) - selectedLink = nil - } - case .ended: - guard let selectedLink = selectedLink else { return avoidSuperCall } - - let text = String(self.text![Range(selectedLink.range, in: self.text!)!]) -// print(getEntity(for: selectedLink.url, text: text)) - if let delegate = delegate { - if let mention = getMention(for: selectedLink.url, text: text) { - delegate.selected(mention: mention) - } else if let tag = getTag(for: selectedLink.url, text: text) { - delegate.selected(tag: tag) - } else { - delegate.selected(url: selectedLink.url) - } - } - - let when = DispatchTime.now() + Double(Int64(0.25 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC) - DispatchQueue.main.asyncAfter(deadline: when) { - self.updateAttributesWhenSelected(false) - self.selectedLink = nil - } - case .cancelled: - updateAttributesWhenSelected(false) - selectedLink = nil - case .stationary: - break - } - - return avoidSuperCall - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - if onTouch(touch) { return } - super.touchesBegan(touches, with: event) - } - - override func touchesMoved(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - if onTouch(touch) { return } - super.touchesMoved(touches, with: event) - } - - override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - if onTouch(touch) { return } - super.touchesCancelled(touches, with: event) - } - - override func touchesEnded(_ touches: Set, with event: UIEvent?) { - guard let touch = touches.first else { return } - if onTouch(touch) { return } - super.touchesEnded(touches, with: event) - } - - private func link(at location: CGPoint) -> (range: NSRange, url: URL)? { - guard textStorage.length > 0 else { return nil } - - let boundingRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: textStorage.length), in: textContainer) - guard boundingRect.contains(location) else { - return nil - } - - let index = layoutManager.glyphIndex(for: location, in: textContainer) - - for (range, url) in links { - if index >= range.location && index <= range.location + range.length { - return (range, url) - } - } - - return nil - } - - private func updateAttributesWhenSelected(_ isSelected: Bool) { - guard let selectedLink = selectedLink else { return } - - var attributes = textStorage.attributes(at: 0, effectiveRange: nil) - - attributes[.foregroundColor] = isSelected ? nil : UIColor.blue - - textStorage.addAttributes(attributes, range: selectedLink.range) - - setNeedsDisplay() - } - } diff --git a/Tusker/Views/StatusTableViewCell.swift b/Tusker/Views/StatusTableViewCell.swift index cbeff0e4..41e7fe16 100644 --- a/Tusker/Views/StatusTableViewCell.swift +++ b/Tusker/Views/StatusTableViewCell.swift @@ -12,6 +12,8 @@ import SwiftSoup protocol StatusTableViewCellDelegate { + func selected(account: Account) + func selected(mention: Mention) func selected(tag: MastodonKit.Tag) @@ -30,6 +32,7 @@ class StatusTableViewCell: UITableViewCell { @IBOutlet weak var avatarImageView: UIImageView! var status: Status! + var account: Account! var avatarURL: URL? @@ -39,6 +42,17 @@ class StatusTableViewCell: UITableViewCell { var links: [NSRange: URL] = [:] + override func awakeFromNib() { + displayNameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + displayNameLabel.isUserInteractionEnabled = true + usernameLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + usernameLabel.isUserInteractionEnabled = true + avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountPressed))) + avatarImageView.isUserInteractionEnabled = true + avatarImageView.layer.cornerRadius = 5 + avatarImageView.layer.masksToBounds = true + } + func updateUI(for status: Status) { self.status = status @@ -48,15 +62,17 @@ class StatusTableViewCell: UITableViewCell { } else { account = status.account } + self.account = account + displayNameLabel.text = account.displayName usernameLabel.text = "@\(account.acct)" - avatarImageView.layer.cornerRadius = 5 - avatarImageView.layer.masksToBounds = true avatarImageView.image = nil if let url = URL(string: account.avatar) { + avatarURL = url AvatarCache.shared.get(url) { image in DispatchQueue.main.async { self.avatarImageView.image = image + self.avatarURL = nil } } } @@ -71,9 +87,13 @@ class StatusTableViewCell: UITableViewCell { } } + @objc func accountPressed() { + delegate?.selected(account: account) + } + } -extension StatusTableViewCell: StatusContentLabelDelegate { +extension StatusTableViewCell: HTMLContentLabelDelegate { func selected(mention: Mention) { delegate?.selected(mention: mention) diff --git a/Tusker/Views/StatusTableViewCell.xib b/Tusker/Views/StatusTableViewCell.xib index 6daf449e..209874ad 100644 --- a/Tusker/Views/StatusTableViewCell.xib +++ b/Tusker/Views/StatusTableViewCell.xib @@ -17,6 +17,7 @@ + @@ -24,12 +25,14 @@