Profile pages

This commit is contained in:
Shadowfacts 2018-08-27 21:27:34 -04:00
parent 4b3d186a2e
commit 05c895db88
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
12 changed files with 765 additions and 327 deletions

View File

@ -10,11 +10,17 @@
04DACE8A212CA6B7009840C4 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE89212CA6B7009840C4 /* Timeline.swift */; }; 04DACE8A212CA6B7009840C4 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE89212CA6B7009840C4 /* Timeline.swift */; };
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* AvatarCache.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 */; }; D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; }; D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D667E5E12134937B0057A976 /* StatusTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D667E5E02134937B0057A976 /* StatusTableViewCell.xib */; }; 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 */; }; 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, ); }; }; 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 */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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; }; D6D4DDCC212518A000E1C4BB /* Tusker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tusker.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -122,9 +134,13 @@
D6BED1722126661300F02DA0 /* Views */ = { D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */, D64A0CD22132153900640E3B /* HTMLContentLabel.swift */,
D64A0CD22132153900640E3B /* StatusContentLabel.swift */, D667E5EE2134C39F0057A976 /* StatusContentLabel.swift */,
D667E5E02134937B0057A976 /* StatusTableViewCell.xib */, D667E5E02134937B0057A976 /* StatusTableViewCell.xib */,
D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */,
D667E5F02134D5050057A976 /* UIViewController+StatusTableViewCellDelegate.swift */,
D667E5E821349EE50057A976 /* ProfileHeaderTableViewCell.xib */,
D667E5EA21349EF80057A976 /* ProfileHeaderTableViewCell.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -193,6 +209,7 @@
D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */, D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */,
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */, D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */,
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
D667E5E621349D4C0057A976 /* ProfileTableViewController.swift */,
); );
path = "View Controllers"; path = "View Controllers";
sourceTree = "<group>"; sourceTree = "<group>";
@ -203,6 +220,7 @@
D6D4DDD3212518A000E1C4BB /* Main.storyboard */, D6D4DDD3212518A000E1C4BB /* Main.storyboard */,
D6F953ED21251A0700CF0F2B /* Timeline.storyboard */, D6F953ED21251A0700CF0F2B /* Timeline.storyboard */,
D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */, D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */,
D667E5E2213499F70057A976 /* Profile.storyboard */,
); );
path = Storyboards; path = Storyboards;
sourceTree = "<group>"; sourceTree = "<group>";
@ -320,11 +338,13 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D667E5E921349EE50057A976 /* ProfileHeaderTableViewCell.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */, D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */,
D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */, D6D4DDD7212518A200E1C4BB /* Assets.xcassets in Resources */,
D6F953EE21251A0700CF0F2B /* Timeline.storyboard in Resources */, D6F953EE21251A0700CF0F2B /* Timeline.storyboard in Resources */,
D6D4DDD5212518A000E1C4BB /* Main.storyboard in Resources */, D6D4DDD5212518A000E1C4BB /* Main.storyboard in Resources */,
D667E5E3213499F70057A976 /* Profile.storyboard in Resources */,
D667E5E12134937B0057A976 /* StatusTableViewCell.xib in Resources */, D667E5E12134937B0057A976 /* StatusTableViewCell.xib in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -356,9 +376,13 @@
04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */, 04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */,
D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */, D6BED174212667E900F02DA0 /* StatusTableViewCell.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
D667E5EF2134C39F0057A976 /* StatusContentLabel.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D6F953EC212519E700CF0F2B /* TimelineTableViewController.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 */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -35,18 +35,18 @@ class AvatarCache {
guard error == nil, guard error == nil,
let data = data, let data = data,
let image = UIImage(data: data) else { let image = UIImage(data: data) else {
let callbacks = self.requestCallbacks.removeValue(forKey: url)! let callbacks = self.requestCallbacks.removeValue(forKey: url)
for callback in callbacks { callbacks?.forEach({ callback in
// todo: default avatar for failed requests // todo: default avatar for failed requests
callback(nil) callback(nil)
} })
return return
} }
let callbacks = self.requestCallbacks.removeValue(forKey: url)! let callbacks = self.requestCallbacks.removeValue(forKey: url)
for callback in callbacks { callbacks?.forEach({ callback in
callback(image) callback(image)
} })
self.cache.setObject(image, forKey: key) self.cache.setObject(image, forKey: key)
} }
task.resume() task.resume()

View File

@ -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>

View File

@ -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 {
}

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
import MastodonKit import MastodonKit
import SwiftSoup
import SafariServices import SafariServices
class TimelineTableViewController: UITableViewController { class TimelineTableViewController: UITableViewController {
@ -52,18 +51,13 @@ class TimelineTableViewController: UITableViewController {
tableView.estimatedRowHeight = 140 tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "StatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell") tableView.register(UINib(nibName: "StatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
}
override func viewWillAppear(_ animated: Bool) {
guard MastodonController.shared.client?.accessToken != nil else { return } guard MastodonController.shared.client?.accessToken != nil else { return }
MastodonController.shared.client.run(timeline.request()) { result in MastodonController.shared.client.run(timeline.request()) { result in
guard case let .success(statuses, pagination) = result else { fatalError() } guard case let .success(statuses, pagination) = result else { fatalError() }
self.statuses = statuses self.statuses = statuses
self.newer = pagination?.previous self.newer = pagination?.previous
self.older = pagination?.next 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 { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? StatusTableViewCell else { let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! StatusTableViewCell
fatalError()
}
let status = statuses[indexPath.row] 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)
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}

View File

@ -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>

View File

@ -2,51 +2,14 @@
// StatusContentLabel.swift // StatusContentLabel.swift
// Tusker // Tusker
// //
// Created by Shadowfacts on 8/25/18. // Created by Shadowfacts on 8/27/18.
// Copyright © 2018 Shadowfacts. All rights reserved. // Copyright © 2018 Shadowfacts. All rights reserved.
// //
import UIKit import UIKit
import MastodonKit import MastodonKit
import SwiftSoup
protocol StatusContentLabelDelegate { class StatusContentLabel: HTMLContentLabel {
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
}
}
var status: Status! { var status: Status! {
didSet { didSet {
@ -54,265 +17,20 @@ class StatusContentLabel: UILabel {
} }
} }
private var _customizing = true override func getMention(for url: URL, text: String) -> Mention? {
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? {
return status.mentions.first(where: { mention -> Bool in return status.mentions.first(where: { mention -> Bool in
(text.dropFirst() == mention.username || text == mention.username) && url.host == URL(string: mention.url)!.host (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 if let tag = status.tags.first(where: { tag -> Bool in
tag.url == url.absoluteString tag.url == url.absoluteString
}) { }) {
return tag return tag
} else if text.starts(with: "#") {
let tag = String(text.dropFirst())
return MastodonKit.Tag(name: tag, url: url.absoluteString)
} else { } 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()
}
} }

View File

@ -12,6 +12,8 @@ import SwiftSoup
protocol StatusTableViewCellDelegate { protocol StatusTableViewCellDelegate {
func selected(account: Account)
func selected(mention: Mention) func selected(mention: Mention)
func selected(tag: MastodonKit.Tag) func selected(tag: MastodonKit.Tag)
@ -30,6 +32,7 @@ class StatusTableViewCell: UITableViewCell {
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: UIImageView!
var status: Status! var status: Status!
var account: Account!
var avatarURL: URL? var avatarURL: URL?
@ -39,6 +42,17 @@ class StatusTableViewCell: UITableViewCell {
var links: [NSRange: URL] = [:] 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) { func updateUI(for status: Status) {
self.status = status self.status = status
@ -48,15 +62,17 @@ class StatusTableViewCell: UITableViewCell {
} else { } else {
account = status.account account = status.account
} }
self.account = account
displayNameLabel.text = account.displayName displayNameLabel.text = account.displayName
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
avatarImageView.layer.cornerRadius = 5
avatarImageView.layer.masksToBounds = true
avatarImageView.image = nil avatarImageView.image = nil
if let url = URL(string: account.avatar) { if let url = URL(string: account.avatar) {
avatarURL = url
AvatarCache.shared.get(url) { image in AvatarCache.shared.get(url) { image in
DispatchQueue.main.async { DispatchQueue.main.async {
self.avatarImageView.image = image 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) { func selected(mention: Mention) {
delegate?.selected(mention: mention) delegate?.selected(mention: mention)

View File

@ -17,6 +17,7 @@
<subviews> <subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="QMP-j2-HLn"> <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"/> <rect key="frame" x="16" y="8" width="50" height="50"/>
<gestureRecognizers/>
<constraints> <constraints>
<constraint firstAttribute="width" constant="50" id="KZ8-d7-8UK"/> <constraint firstAttribute="width" constant="50" id="KZ8-d7-8UK"/>
<constraint firstAttribute="height" constant="50" id="nMi-Gq-JyV"/> <constraint firstAttribute="height" constant="50" id="nMi-Gq-JyV"/>
@ -24,12 +25,14 @@
</imageView> </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"> <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"/> <rect key="frame" x="74" y="8" width="103" height="27.5"/>
<gestureRecognizers/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/> <nil key="textColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </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"> <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"/> <rect key="frame" x="185" y="8" width="174" height="21"/>
<gestureRecognizers/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>

View File

@ -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)
}
}