forked from shadowfacts/Tusker
Profile pages
This commit is contained in:
parent
4b3d186a2e
commit
05c895db88
|
@ -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 = "<group>"; };
|
||||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
|
||||
04DACE8D212CC7CC009840C4 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = "<group>"; };
|
||||
D64A0CD22132153900640E3B /* StatusContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentLabel.swift; sourceTree = "<group>"; };
|
||||
D64A0CD22132153900640E3B /* HTMLContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLContentLabel.swift; sourceTree = "<group>"; };
|
||||
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
|
||||
D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = "<group>"; };
|
||||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
|
||||
D667E5E02134937B0057A976 /* StatusTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D667E5E2213499F70057A976 /* Profile.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Profile.storyboard; sourceTree = "<group>"; };
|
||||
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTableViewController.swift; sourceTree = "<group>"; };
|
||||
D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderTableViewCell.xib; sourceTree = "<group>"; };
|
||||
D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D667E5EE2134C39F0057A976 /* StatusContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentLabel.swift; sourceTree = "<group>"; };
|
||||
D667E5F02134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+StatusTableViewCellDelegate.swift"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>";
|
||||
|
@ -193,6 +209,7 @@
|
|||
D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */,
|
||||
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */,
|
||||
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
|
||||
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */,
|
||||
);
|
||||
path = "View Controllers";
|
||||
sourceTree = "<group>";
|
||||
|
@ -203,6 +220,7 @@
|
|||
D6D4DDD3212518A000E1C4BB /* Main.storyboard */,
|
||||
D6F953ED21251A0700CF0F2B /* Timeline.storyboard */,
|
||||
D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */,
|
||||
D667E5E2213499F70057A976 /* Profile.storyboard */,
|
||||
);
|
||||
path = Storyboards;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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;
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14313.13.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="HMn-Wn-5Ab">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14283.9"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Profile Table View Controller-->
|
||||
<scene sceneID="b2p-rd-ie4">
|
||||
<objects>
|
||||
<tableViewController id="HMn-Wn-5Ab" customClass="ProfileTableViewController" customModule="Tusker" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="O5e-jX-tC3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="HMn-Wn-5Ab" id="nAT-a9-DHC"/>
|
||||
<outlet property="delegate" destination="HMn-Wn-5Ab" id="PbX-SY-1Qw"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<navigationItem key="navigationItem" id="bvr-ex-deZ"/>
|
||||
<refreshControl key="refreshControl" opaque="NO" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" enabled="NO" contentHorizontalAlignment="center" contentVerticalAlignment="center" id="aAC-id-7YO">
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<connections>
|
||||
<action selector="refreshStatuses:" destination="HMn-Wn-5Ab" eventType="valueChanged" id="uPt-pK-1iL"/>
|
||||
</connections>
|
||||
</refreshControl>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="C4T-mP-V8U" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-482" y="90"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
|
@ -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 {
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
if onTouch(touch) { return }
|
||||
super.touchesBegan(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
if onTouch(touch) { return }
|
||||
super.touchesMoved(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
if onTouch(touch) { return }
|
||||
super.touchesCancelled(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, 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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="14313.13.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14283.9"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="ProfileHeaderTableViewCell" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="270"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Fw7-OL-iy5">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="150"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="150" id="y43-4B-slK"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="LjK-72-Bez">
|
||||
<rect key="frame" x="144" y="158" width="215" height="24"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="20"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Note" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="I0n-aP-dJP" customClass="HTMLContentLabel" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="16" y="215" width="343" height="39"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="MIj-OR-NOR">
|
||||
<rect key="frame" x="144" y="190" width="215" height="17"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="14"/>
|
||||
<color key="textColor" cocoaTouchSystemColor="darkTextColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="KyB-ey-l11">
|
||||
<rect key="frame" x="16" y="90" width="120" height="120"/>
|
||||
<subviews>
|
||||
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="tH8-sR-DHC">
|
||||
<rect key="frame" x="2" y="2" width="116" height="116"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="116" id="k9v-I0-Aoz"/>
|
||||
<constraint firstAttribute="height" constant="116" id="sz5-86-5Iq"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstItem="tH8-sR-DHC" firstAttribute="centerX" secondItem="KyB-ey-l11" secondAttribute="centerX" id="KT6-FP-LsA"/>
|
||||
<constraint firstAttribute="height" constant="120" id="LVm-OC-cGm"/>
|
||||
<constraint firstAttribute="width" constant="120" id="Obt-ZN-POD"/>
|
||||
<constraint firstItem="tH8-sR-DHC" firstAttribute="centerY" secondItem="KyB-ey-l11" secondAttribute="centerY" id="nYu-RE-MfA"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="Fw7-OL-iy5" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="0fI-0y-cXG"/>
|
||||
<constraint firstItem="KyB-ey-l11" firstAttribute="centerY" secondItem="Fw7-OL-iy5" secondAttribute="bottom" id="AXr-6X-FJ8"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="MIj-OR-NOR" secondAttribute="trailing" constant="16" id="AwT-Vi-CLa"/>
|
||||
<constraint firstItem="LjK-72-Bez" firstAttribute="leading" secondItem="KyB-ey-l11" secondAttribute="trailing" constant="8" id="CIO-tn-hJC"/>
|
||||
<constraint firstItem="I0n-aP-dJP" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="JlW-us-7Wd"/>
|
||||
<constraint firstItem="LjK-72-Bez" firstAttribute="top" secondItem="Fw7-OL-iy5" secondAttribute="bottom" constant="8" id="Kvl-sz-Lv3"/>
|
||||
<constraint firstItem="Fw7-OL-iy5" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" id="LqH-lE-AIe"/>
|
||||
<constraint firstItem="KyB-ey-l11" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="16" id="NN7-5B-k1Q"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="I0n-aP-dJP" secondAttribute="trailing" constant="16" id="aaC-xJ-vvs"/>
|
||||
<constraint firstItem="Fw7-OL-iy5" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="d1j-6d-hBb"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="LjK-72-Bez" secondAttribute="trailing" constant="16" id="hn9-c3-iNH"/>
|
||||
<constraint firstItem="MIj-OR-NOR" firstAttribute="leading" secondItem="KyB-ey-l11" secondAttribute="trailing" constant="8" id="iG7-yZ-9u3"/>
|
||||
<constraint firstItem="MIj-OR-NOR" firstAttribute="top" secondItem="LjK-72-Bez" secondAttribute="bottom" constant="8" id="nMM-6t-bjX"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="I0n-aP-dJP" secondAttribute="bottom" constant="16" id="nPu-bH-pLA"/>
|
||||
<constraint firstItem="I0n-aP-dJP" firstAttribute="top" secondItem="MIj-OR-NOR" secondAttribute="bottom" constant="8" id="upb-uc-3BZ"/>
|
||||
</constraints>
|
||||
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
|
||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||
<connections>
|
||||
<outlet property="avatarContainerView" destination="KyB-ey-l11" id="45s-jV-l8L"/>
|
||||
<outlet property="avatarImageView" destination="tH8-sR-DHC" id="6ll-yL-g1o"/>
|
||||
<outlet property="displayNameLabel" destination="LjK-72-Bez" id="nIU-ey-H1C"/>
|
||||
<outlet property="headerImageView" destination="Fw7-OL-iy5" id="6sv-E5-D73"/>
|
||||
<outlet property="noteLabel" destination="I0n-aP-dJP" id="7yW-mE-jxY"/>
|
||||
<outlet property="usernameLabel" destination="MIj-OR-NOR" id="e1I-N7-rKx"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="40.799999999999997" y="98.950524737631198"/>
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
|
@ -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<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
if onTouch(touch) { return }
|
||||
super.touchesBegan(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
if onTouch(touch) { return }
|
||||
super.touchesMoved(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
if onTouch(touch) { return }
|
||||
super.touchesCancelled(touches, with: event)
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, 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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
<subviews>
|
||||
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="QMP-j2-HLn">
|
||||
<rect key="frame" x="16" y="8" width="50" height="50"/>
|
||||
<gestureRecognizers/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" constant="50" id="KZ8-d7-8UK"/>
|
||||
<constraint firstAttribute="height" constant="50" id="nMi-Gq-JyV"/>
|
||||
|
@ -24,12 +25,14 @@
|
|||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="gll-xe-FSr">
|
||||
<rect key="frame" x="74" y="8" width="103" height="27.5"/>
|
||||
<gestureRecognizers/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="j89-zc-SFa">
|
||||
<rect key="frame" x="185" y="8" width="174" height="21"/>
|
||||
<gestureRecognizers/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// UIViewController+StatusTableViewCellDelegate.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/27/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import MastodonKit
|
||||
import SafariServices
|
||||
|
||||
extension UIViewController: StatusTableViewCellDelegate {
|
||||
|
||||
func selected(account: Account) {
|
||||
guard let navigationController = navigationController else {
|
||||
fatalError("Can't show profile VC when not in navigation controller")
|
||||
}
|
||||
let vc = ProfileTableViewController.create(for: account)
|
||||
navigationController.pushViewController(vc, animated: true)
|
||||
}
|
||||
|
||||
func selected(mention: Mention) {
|
||||
|
||||
}
|
||||
|
||||
func selected(tag: Tag) {
|
||||
|
||||
}
|
||||
|
||||
func selected(url: URL) {
|
||||
let vc = SFSafariViewController(url: url)
|
||||
present(vc, animated: true)
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue