Compare commits

..

6 Commits

12 changed files with 227 additions and 69 deletions

View File

@ -305,6 +305,7 @@
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; }; D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; }; D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; }; D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
@ -698,6 +699,7 @@
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; }; D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; };
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; }; D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; }; D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
@ -933,6 +935,7 @@
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */, D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
D61F759A29384F9C00C0B37F /* FilterMO.swift */, D61F759A29384F9C00C0B37F /* FilterMO.swift */,
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
D6D706A62948D4D0000827ED /* TimlineState.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
); );
@ -2071,6 +2074,7 @@
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */, D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */, D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,

View File

@ -355,6 +355,15 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
func getTimelineState(timeline: Timeline) -> TimelineState? {
do {
let req = TimelineState.fetchRequest(timeline: timeline)
return try viewContext.fetch(req).first
} catch {
return nil
}
}
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
let changes = hasChangedSavedHashtagsOrInstances(notification) let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags { if changes.hashtags {

View File

@ -18,6 +18,12 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
return NSFetchRequest<StatusMO>(entityName: "Status") return NSFetchRequest<StatusMO>(entityName: "Status")
} }
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<StatusMO> {
let req = Self.fetchRequest()
req.predicate = NSPredicate(format: "id = %@", id)
return req
}
@NSManaged public var applicationName: String? @NSManaged public var applicationName: String?
@NSManaged private var attachmentsData: Data? @NSManaged private var attachmentsData: Data?
@NSManaged private var bookmarkedInternal: Bool @NSManaged private var bookmarkedInternal: Bool

View File

@ -0,0 +1,89 @@
//
// TimlineState.swift
// Tusker
//
// Created by Shadowfacts on 12/13/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(TimelineState)
public final class TimelineState: NSManagedObject {
@nonobjc public class func fetchRequest(timeline: Timeline) -> NSFetchRequest<TimelineState> {
let req = NSFetchRequest<TimelineState>(entityName: "TimelineState")
req.predicate = NSPredicate(format: "timelineKind = %@", toTimelineKind(timeline))
return req
}
@NSManaged private var timelineKind: String
@NSManaged public var centerStatusID: String?
@NSManaged private var statuses: NSOrderedSet
var timeline: Timeline {
get { fromTimelineKind(timelineKind) }
set { timelineKind = toTimelineKind(newValue) }
}
var statusMOs: [StatusMO] {
statuses.array as! [StatusMO]
}
convenience init(timeline: Timeline, context: NSManagedObjectContext) {
self.init(context: context)
self.timeline = timeline
}
func setStatuses(_ statusIDs: [String]) {
let context = managedObjectContext!
// todo: this feels really inefficient, but I'm not sure if it's better or worse than doing a single "id IN %@" fetch and sorting after
let mos = statusIDs.compactMap { try? context.fetch(StatusMO.fetchRequest(id: $0)).first }
self.statuses = NSOrderedSet(array: mos)
}
}
// blergh, this is the simplest way of getting the Timeline into a format that A) CoreData can handle and B) is usable in the predicate
private func toTimelineKind(_ timeline: Timeline) -> String {
switch timeline {
case .home:
return "home"
case .public(local: true):
return "local"
case .public(local: false):
return "federated"
case .direct:
return "direct"
case .tag(hashtag: let name):
return "hashtag:\(name)"
case .list(id: let id):
return "list:\(id)"
}
}
private func fromTimelineKind(_ kind: String) -> Timeline {
if kind == "home" {
return .home
} else if kind == "local" {
return .public(local: true)
} else if kind == "federated" {
return .public(local: false)
} else if kind == "direct" {
return .direct
} else if kind.starts(with: "hashtag:") {
return .tag(hashtag: String(trimmingPrefix("hashtag:", of: kind)))
} else if kind.starts(with: "list:") {
return .list(id: String(trimmingPrefix("list:", of: kind)))
} else {
fatalError("invalid timeline kind \(kind)")
}
}
// replace with Collection.trimmingPrefix
@available(iOS, obsoleted: 16.0)
private func trimmingPrefix(_ prefix: String, of str: String) -> Substring {
return str[str.index(str.startIndex, offsetBy: prefix.count)...]
}

View File

@ -105,10 +105,16 @@
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
<constraint value="id"/> <constraint value="id"/>
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<entity name="TimelineState" representedClassName="TimelineState" syncable="YES">
<attribute name="centerStatusID" optional="YES" attributeType="String"/>
<attribute name="timelineKind" attributeType="String" valueTransformerName="pachydermTimeline" customClassName="Tusker.TimelineContainer"/>
<relationship name="statuses" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="Status" inverseName="timelines" inverseEntity="Status"/>
</entity>
</model> </model>

View File

@ -131,7 +131,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60) minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (timelines.@count = 0)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

@ -17,8 +17,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
@IBOutlet weak var scrollView: UIScrollView! @IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var topControlsView: UIView! @IBOutlet weak var topControlsView: UIView!
@IBOutlet weak var bottomControlsView: UIView! @IBOutlet weak var descriptionTextView: UITextView!
@IBOutlet weak var descriptionLabel: UILabel!
private var shareContainer: UIView! private var shareContainer: UIView!
private var closeContainer: UIView! private var closeContainer: UIView!
@ -47,6 +46,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
var shrinkGestureEnabled = true var shrinkGestureEnabled = true
private var isInitialAppearance = true
private var skipUpdatingControlsWhileZooming = false
private var prevZoomScale: CGFloat? private var prevZoomScale: CGFloat?
private var isGrayscale = false private var isGrayscale = false
private var contentViewSizeObservation: NSKeyValueObservation? private var contentViewSizeObservation: NSKeyValueObservation?
@ -99,9 +100,14 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
if let imageDescription = imageDescription, if let imageDescription = imageDescription,
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { !imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
descriptionLabel.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines) descriptionTextView.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
descriptionTextView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
// i'm not sure why .automatic doesn't work for this
descriptionTextView.contentInsetAdjustmentBehavior = .always
let height = min(150, descriptionTextView.contentSize.height)
descriptionTextView.topAnchor.constraint(equalTo: descriptionTextView.safeAreaLayoutGuide.bottomAnchor, constant: -(height + 16)).isActive = true
} else { } else {
bottomControlsView.isHidden = true descriptionTextView.isHidden = true
} }
if shrinkGestureEnabled { if shrinkGestureEnabled {
@ -121,7 +127,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
accessibilityElements = [ accessibilityElements = [
topControlsView!, topControlsView!,
contentView, contentView,
bottomControlsView!, descriptionTextView!,
] ]
} }
@ -211,9 +217,11 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
let heightScale = maxHeight / contentView.intrinsicContentSize.height let heightScale = maxHeight / contentView.intrinsicContentSize.height
let widthScale = view.bounds.width / contentView.intrinsicContentSize.width let widthScale = view.bounds.width / contentView.intrinsicContentSize.width
let minScale = min(widthScale, heightScale) let minScale = min(widthScale, heightScale)
skipUpdatingControlsWhileZooming = true
scrollView.minimumZoomScale = minScale scrollView.minimumZoomScale = minScale
scrollView.zoomScale = minScale scrollView.zoomScale = minScale
scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2 scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2
skipUpdatingControlsWhileZooming = false
centerImage() centerImage()
@ -243,6 +251,26 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
} }
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
// the controls view transforms take the safe area insets into account, so they need to be updated
updateControlsView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// on the first appearance, the text view flashes its own scroll indicators automatically
// so we only need to do it on subsequent appearances
if isInitialAppearance {
isInitialAppearance = false
} else {
if animated && controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
}
}
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages isGrayscale = Preferences.shared.grayscaleImages
@ -257,17 +285,20 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
self.contentView.setControlsVisible(controlsVisible) self.contentView.setControlsVisible(controlsVisible)
self.updateControlsView() self.updateControlsView()
} }
if controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
} else { } else {
updateControlsView() updateControlsView()
} }
} }
func updateControlsView() { func updateControlsView() {
let topOffset = self.controlsVisible ? 0 : -self.topControlsView.bounds.height let topOffset = self.controlsVisible ? 0 : -(self.topControlsView.bounds.height + self.view.safeAreaInsets.top)
self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset) self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset)
if self.imageDescription != nil { if self.imageDescription != nil {
let bottomOffset = self.controlsVisible ? 0 : self.bottomControlsView.bounds.height + self.view.safeAreaInsets.bottom let bottomOffset = self.controlsVisible ? 0 : self.descriptionTextView.bounds.height + self.view.safeAreaInsets.bottom
self.bottomControlsView.transform = CGAffineTransform(translationX: 0, y: bottomOffset) self.descriptionTextView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
} }
} }
@ -277,10 +308,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
func scrollViewDidZoom(_ scrollView: UIScrollView) { func scrollViewDidZoom(_ scrollView: UIScrollView) {
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
if scrollView.zoomScale <= scrollView.minimumZoomScale { if !skipUpdatingControlsWhileZooming {
setControlsVisible(true, animated: true) if scrollView.zoomScale <= scrollView.minimumZoomScale {
} else if scrollView.zoomScale > prevZoomScale { setControlsVisible(true, animated: true)
setControlsVisible(false, animated: true) } else if scrollView.zoomScale > prevZoomScale {
setControlsVisible(false, animated: true)
}
} }
self.prevZoomScale = scrollView.zoomScale self.prevZoomScale = scrollView.zoomScale
} }

View File

@ -10,8 +10,7 @@
<objects> <objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target"> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
<connections> <connections>
<outlet property="bottomControlsView" destination="rPa-Zu-T6g" id="Rgz-AQ-9nt"/> <outlet property="descriptionTextView" destination="JZk-BO-2Vh" id="cby-Hc-ezg"/>
<outlet property="descriptionLabel" destination="eo5-fc-RV8" id="vrW-RJ-y5k"/>
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/> <outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
<outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/> <outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/> <outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
@ -29,41 +28,34 @@
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a"> <view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/> <rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
</view> </view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" editable="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JZk-BO-2Vh">
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/> <rect key="frame" x="0.0" y="517" width="375" height="150"/>
<subviews> <color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="displayP3"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eo5-fc-RV8">
<rect key="frame" x="16" y="8" width="343" height="20.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="eo5-fc-RV8" firstAttribute="top" secondItem="rPa-Zu-T6g" secondAttribute="top" constant="8" id="6n3-E0-2G6"/> <constraint firstAttribute="height" constant="150" placeholder="YES" id="YfV-kQ-0RT"/>
<constraint firstAttribute="trailing" secondItem="eo5-fc-RV8" secondAttribute="trailing" constant="16" id="6uL-vY-tqk"/>
<constraint firstItem="eo5-fc-RV8" firstAttribute="leading" secondItem="rPa-Zu-T6g" secondAttribute="leading" constant="16" id="KIF-vw-K7n"/>
<constraint firstAttribute="bottom" secondItem="eo5-fc-RV8" secondAttribute="bottom" constant="16" id="v43-mS-tyR"/>
</constraints> </constraints>
</view> <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/> <viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/> <gestureRecognizers/>
<constraints> <constraints>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerY" secondItem="BJw-5C-9nT" secondAttribute="centerY" id="0Xb-ib-2hg"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="centerY" secondItem="BJw-5C-9nT" secondAttribute="centerY" id="0Xb-ib-2hg"/>
<constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="rPa-Zu-T6g" secondAttribute="trailing" id="2GG-7P-Qv1"/> <constraint firstAttribute="bottom" secondItem="JZk-BO-2Vh" secondAttribute="bottom" id="7Z2-gW-sPj"/>
<constraint firstItem="w1g-VC-Ll9" firstAttribute="bottom" secondItem="rPa-Zu-T6g" secondAttribute="bottom" id="3qf-5e-vl0"/>
<constraint firstItem="kHo-B9-R7a" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="IvH-gU-Kie"/> <constraint firstItem="kHo-B9-R7a" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="IvH-gU-Kie"/>
<constraint firstAttribute="trailing" secondItem="JZk-BO-2Vh" secondAttribute="trailing" id="JgV-jy-qjS"/>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerX" secondItem="BJw-5C-9nT" secondAttribute="centerX" id="KMe-Zc-NZq"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="centerX" secondItem="BJw-5C-9nT" secondAttribute="centerX" id="KMe-Zc-NZq"/>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="width" secondItem="BJw-5C-9nT" secondAttribute="width" id="Onj-l9-fBu"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="width" secondItem="BJw-5C-9nT" secondAttribute="width" id="Onj-l9-fBu"/>
<constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="kHo-B9-R7a" secondAttribute="trailing" id="Uh0-ub-R9V"/> <constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="kHo-B9-R7a" secondAttribute="trailing" id="Uh0-ub-R9V"/>
<constraint firstItem="rPa-Zu-T6g" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="asz-Xj-FUC"/>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/>
<constraint firstItem="JZk-BO-2Vh" firstAttribute="leading" secondItem="BJw-5C-9nT" secondAttribute="leading" id="kkj-O9-1rE"/>
<constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/> <constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/>
</constraints> </constraints>
<point key="canvasLocation" x="-164" y="476"/> <point key="canvasLocation" x="-164" y="475.41229385307349"/>
</view> </view>
</objects> </objects>
</document> </document>

View File

@ -15,6 +15,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let filterer: Filterer let filterer: Filterer
var persistsState = false
private(set) var controller: TimelineLikeController<TimelineItem>! private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>() let confirmLoadMore = PassthroughSubject<Void, Never>()
// stored separately because i don't want to query the snapshot every time the user scrolls // stored separately because i don't want to query the snapshot every time the user scrolls
@ -196,7 +198,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
if doRestore() { if restoreState() {
Task { Task {
await checkPresent(jumpImmediately: false) await checkPresent(jumpImmediately: false)
} }
@ -227,22 +229,23 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
disappearedAt = Date() disappearedAt = Date()
saveState()
} }
func stateRestorationActivity() -> NSUserActivity? { private func saveState() {
guard isViewLoaded else { guard isViewLoaded,
return nil persistsState else {
return
} }
let visible = collectionView.indexPathsForVisibleItems.sorted() let visible = collectionView.indexPathsForVisibleItems.sorted()
let snapshot = dataSource.snapshot() let snapshot = dataSource.snapshot()
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size) let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY) let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
guard let currentAccountID = mastodonController.accountInfo?.id, guard !visible.isEmpty,
!visible.isEmpty,
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses), let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
let rawCenterVisible = collectionView.indexPathForItem(at: midPoint), let rawCenterVisible = collectionView.indexPathForItem(at: midPoint),
let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else { let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else {
return nil return
} }
let allItems = snapshot.itemIdentifiers(inSection: .statuses) let allItems = snapshot.itemIdentifiers(inSection: .statuses)
@ -282,44 +285,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} else { } else {
fatalError() fatalError()
} }
stateRestorationLogger.debug("TimelineViewController: creating state restoration activity with topID \(centerVisibleID)") stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) ?? TimelineState(timeline: timeline, context: mastodonController.persistentContainer.viewContext)
state.setStatuses(ids)
state.centerStatusID = centerVisibleID
mastodonController.persistentContainer.save(context: mastodonController.persistentContainer.viewContext)
}
func stateRestorationActivity() -> NSUserActivity? {
guard isViewLoaded,
let currentAccountID = mastodonController.accountInfo?.id else {
return nil
}
let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)! let activity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: currentAccountID)!
activity.addUserInfoEntries(from: [
"statusIDs": ids,
"centerID": centerVisibleID,
])
activity.isEligibleForPrediction = false activity.isEligibleForPrediction = false
return activity return activity
} }
func restoreActivity(_ activity: NSUserActivity) { private func restoreState() -> Bool {
self.activityToRestore = activity guard persistsState,
} Preferences.shared.timelineStateRestoration,
let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else {
private func doRestore() -> Bool {
guard let activity = activityToRestore,
Preferences.shared.timelineStateRestoration else {
return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false
}
activityToRestore = nil
let existingStatuses = statusIDs.filter { mastodonController.persistentContainer.status(for: $0) != nil }
guard !existingStatuses.isEmpty else {
return false return false
} }
let statusIDs = state.statusMOs.map(\.id)
loadViewIfNeeded() loadViewIfNeeded()
controller.restoreInitial { controller.restoreInitial {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let items = existingStatuses.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) } let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items, toSection: .statuses) snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) { dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String, if let centerID = state.centerStatusID,
let index = existingStatuses.firstIndex(of: centerID), let index = statusIDs.firstIndex(of: centerID),
let indexPath = self.dataSource.indexPath(for: items[index]) { let indexPath = self.dataSource.indexPath(for: items[index]) {
// it sometimes takes multiple attempts to convert on the right scroll position // it sometimes takes multiple attempts to convert on the right scroll position
// since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop // since we're dealing with a bunch of unmeasured cells, so just try a few times in a loop
@ -400,6 +399,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return return
} }
disappearedAt = Date() disappearedAt = Date()
saveState()
} }
@objc func refresh() { @objc func refresh() {

View File

@ -22,12 +22,15 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
let home = TimelineViewController(for: .home, mastodonController: mastodonController) let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle home.title = homeTitle
home.persistsState = true
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController) let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle federated.title = federatedTitle
federated.persistsState = true
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController) let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle local.title = localTitle
local.persistsState = true
super.init(pages: [ super.init(pages: [
(.home, "Home", home), (.home, "Home", home),
@ -83,9 +86,6 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
return return
} }
selectPage(page, animated: false) selectPage(page, animated: false)
// can't use currentIndex here because the view isn't loaded yet, and so the page wasn't actually updated by the selectPage call
let timelineVC = pageControllers[pages.firstIndex(of: page)!] as! TimelineViewController
timelineVC.restoreActivity(activity)
} }
@objc private func filtersPressed() { @objc private func filtersPressed() {

View File

@ -394,6 +394,8 @@ class CustomAlertActionButton: UIControl {
self.isContextMenuInteractionEnabled = true self.isContextMenuInteractionEnabled = true
self.showsMenuAsPrimaryAction = action.handler == nil self.showsMenuAsPrimaryAction = action.handler == nil
} }
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -429,6 +431,17 @@ class CustomAlertActionButton: UIControl {
super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator) super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
} }
@objc func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) {
switch recognizer.state {
case .began, .changed:
backgroundColor = .secondarySystemFill
case .ended:
backgroundColor = nil
default:
break
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event) super.touchesBegan(touches, with: event)

View File

@ -19,6 +19,7 @@ class StatusMetaIndicatorsView: UIView {
var secondaryAxisAlignment: Alignment = .leading var secondaryAxisAlignment: Alignment = .leading
private var images: [UIImageView] = [] private var images: [UIImageView] = []
private var isUsingSingleAxis = false private var isUsingSingleAxis = false
private var statusID: String?
private var needsSingleAxis: Bool { private var needsSingleAxis: Bool {
traitCollection.preferredContentSizeCategory > .extraLarge traitCollection.preferredContentSizeCategory > .extraLarge
@ -61,6 +62,11 @@ class StatusMetaIndicatorsView: UIView {
} }
func updateUI(status: StatusMO) { func updateUI(status: StatusMO) {
guard statusID != status.id else {
return
}
statusID = status.id
var images: [UIImage] = [] var images: [UIImage] = []
if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil { if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil {