Compare commits
6 Commits
3691c3f483
...
ff4dff1147
Author | SHA1 | Date |
---|---|---|
Shadowfacts | ff4dff1147 | |
Shadowfacts | ba1eed7a85 | |
Shadowfacts | 0c9f6e02bd | |
Shadowfacts | 565d17970f | |
Shadowfacts | dc3c2d027c | |
Shadowfacts | ba2c34fdd6 |
|
@ -305,6 +305,7 @@
|
|||
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
|
||||
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.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 */; };
|
||||
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -933,6 +935,7 @@
|
|||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
||||
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
||||
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
||||
D6D706A62948D4D0000827ED /* TimlineState.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||
);
|
||||
|
@ -2071,6 +2074,7 @@
|
|||
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
|
||||
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
|
||||
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
|
||||
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
|
||||
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
|
||||
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
|
||||
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
|
||||
|
|
|
@ -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) {
|
||||
let changes = hasChangedSavedHashtagsOrInstances(notification)
|
||||
if changes.hashtags {
|
||||
|
|
|
@ -18,6 +18,12 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
|
|||
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 private var attachmentsData: Data?
|
||||
@NSManaged private var bookmarkedInternal: Bool
|
||||
|
|
|
@ -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)...]
|
||||
}
|
|
@ -105,10 +105,16 @@
|
|||
<attribute name="visibilityString" attributeType="String"/>
|
||||
<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="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="id"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</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>
|
|
@ -131,7 +131,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
minDate.addTimeInterval(-7 * 24 * 60 * 60)
|
||||
|
||||
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)
|
||||
deleteStatusReq.resultType = .resultTypeCount
|
||||
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {
|
||||
|
|
|
@ -17,8 +17,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
@IBOutlet weak var scrollView: UIScrollView!
|
||||
@IBOutlet weak var topControlsView: UIView!
|
||||
@IBOutlet weak var bottomControlsView: UIView!
|
||||
@IBOutlet weak var descriptionLabel: UILabel!
|
||||
@IBOutlet weak var descriptionTextView: UITextView!
|
||||
|
||||
private var shareContainer: UIView!
|
||||
private var closeContainer: UIView!
|
||||
|
@ -47,6 +46,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
}
|
||||
var shrinkGestureEnabled = true
|
||||
|
||||
private var isInitialAppearance = true
|
||||
private var skipUpdatingControlsWhileZooming = false
|
||||
private var prevZoomScale: CGFloat?
|
||||
private var isGrayscale = false
|
||||
private var contentViewSizeObservation: NSKeyValueObservation?
|
||||
|
@ -99,9 +100,14 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
if let imageDescription = imageDescription,
|
||||
!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 {
|
||||
bottomControlsView.isHidden = true
|
||||
descriptionTextView.isHidden = true
|
||||
}
|
||||
|
||||
if shrinkGestureEnabled {
|
||||
|
@ -121,7 +127,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
accessibilityElements = [
|
||||
topControlsView!,
|
||||
contentView,
|
||||
bottomControlsView!,
|
||||
descriptionTextView!,
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -211,9 +217,11 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
let heightScale = maxHeight / contentView.intrinsicContentSize.height
|
||||
let widthScale = view.bounds.width / contentView.intrinsicContentSize.width
|
||||
let minScale = min(widthScale, heightScale)
|
||||
skipUpdatingControlsWhileZooming = true
|
||||
scrollView.minimumZoomScale = minScale
|
||||
scrollView.zoomScale = minScale
|
||||
scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2
|
||||
skipUpdatingControlsWhileZooming = false
|
||||
|
||||
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() {
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
|
@ -257,17 +285,20 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
self.contentView.setControlsVisible(controlsVisible)
|
||||
self.updateControlsView()
|
||||
}
|
||||
if controlsVisible && !descriptionTextView.isHidden {
|
||||
descriptionTextView.flashScrollIndicators()
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
if self.imageDescription != nil {
|
||||
let bottomOffset = self.controlsVisible ? 0 : self.bottomControlsView.bounds.height + self.view.safeAreaInsets.bottom
|
||||
self.bottomControlsView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
|
||||
let bottomOffset = self.controlsVisible ? 0 : self.descriptionTextView.bounds.height + self.view.safeAreaInsets.bottom
|
||||
self.descriptionTextView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -277,10 +308,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
|
||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||
setControlsVisible(true, animated: true)
|
||||
} else if scrollView.zoomScale > prevZoomScale {
|
||||
setControlsVisible(false, animated: true)
|
||||
if !skipUpdatingControlsWhileZooming {
|
||||
if scrollView.zoomScale <= scrollView.minimumZoomScale {
|
||||
setControlsVisible(true, animated: true)
|
||||
} else if scrollView.zoomScale > prevZoomScale {
|
||||
setControlsVisible(false, animated: true)
|
||||
}
|
||||
}
|
||||
self.prevZoomScale = scrollView.zoomScale
|
||||
}
|
||||
|
|
|
@ -10,8 +10,7 @@
|
|||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="bottomControlsView" destination="rPa-Zu-T6g" id="Rgz-AQ-9nt"/>
|
||||
<outlet property="descriptionLabel" destination="eo5-fc-RV8" id="vrW-RJ-y5k"/>
|
||||
<outlet property="descriptionTextView" destination="JZk-BO-2Vh" id="cby-Hc-ezg"/>
|
||||
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
|
||||
<outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
|
||||
<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">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
|
||||
</view>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
|
||||
<rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
|
||||
<subviews>
|
||||
<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"/>
|
||||
<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="517" width="375" height="150"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="displayP3"/>
|
||||
<constraints>
|
||||
<constraint firstItem="eo5-fc-RV8" firstAttribute="top" secondItem="rPa-Zu-T6g" secondAttribute="top" constant="8" id="6n3-E0-2G6"/>
|
||||
<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"/>
|
||||
<constraint firstAttribute="height" constant="150" placeholder="YES" id="YfV-kQ-0RT"/>
|
||||
</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>
|
||||
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<gestureRecognizers/>
|
||||
<constraints>
|
||||
<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 firstItem="w1g-VC-Ll9" firstAttribute="bottom" secondItem="rPa-Zu-T6g" secondAttribute="bottom" id="3qf-5e-vl0"/>
|
||||
<constraint firstAttribute="bottom" secondItem="JZk-BO-2Vh" secondAttribute="bottom" id="7Z2-gW-sPj"/>
|
||||
<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="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="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="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"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="-164" y="476"/>
|
||||
<point key="canvasLocation" x="-164" y="475.41229385307349"/>
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
||||
|
|
|
@ -15,6 +15,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
weak var mastodonController: MastodonController!
|
||||
let filterer: Filterer
|
||||
|
||||
var persistsState = false
|
||||
|
||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||
// 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 doRestore() {
|
||||
if restoreState() {
|
||||
Task {
|
||||
await checkPresent(jumpImmediately: false)
|
||||
}
|
||||
|
@ -227,22 +229,23 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
super.viewDidDisappear(animated)
|
||||
|
||||
disappearedAt = Date()
|
||||
saveState()
|
||||
}
|
||||
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
guard isViewLoaded else {
|
||||
return nil
|
||||
private func saveState() {
|
||||
guard isViewLoaded,
|
||||
persistsState else {
|
||||
return
|
||||
}
|
||||
let visible = collectionView.indexPathsForVisibleItems.sorted()
|
||||
let snapshot = dataSource.snapshot()
|
||||
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.bounds.size)
|
||||
let midPoint = CGPoint(x: visibleRect.midX, y: visibleRect.midY)
|
||||
guard let currentAccountID = mastodonController.accountInfo?.id,
|
||||
!visible.isEmpty,
|
||||
guard !visible.isEmpty,
|
||||
let statusesSection = snapshot.sectionIdentifiers.firstIndex(of: .statuses),
|
||||
let rawCenterVisible = collectionView.indexPathForItem(at: midPoint),
|
||||
let centerVisible = visible.first(where: { $0.section == statusesSection && $0 >= rawCenterVisible }) else {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
let allItems = snapshot.itemIdentifiers(inSection: .statuses)
|
||||
|
||||
|
@ -282,44 +285,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
} else {
|
||||
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)!
|
||||
activity.addUserInfoEntries(from: [
|
||||
"statusIDs": ids,
|
||||
"centerID": centerVisibleID,
|
||||
])
|
||||
activity.isEligibleForPrediction = false
|
||||
return activity
|
||||
}
|
||||
|
||||
func restoreActivity(_ activity: NSUserActivity) {
|
||||
self.activityToRestore = activity
|
||||
}
|
||||
|
||||
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 {
|
||||
private func restoreState() -> Bool {
|
||||
guard persistsState,
|
||||
Preferences.shared.timelineStateRestoration,
|
||||
let state = mastodonController.persistentContainer.getTimelineState(timeline: timeline) else {
|
||||
return false
|
||||
}
|
||||
let statusIDs = state.statusMOs.map(\.id)
|
||||
loadViewIfNeeded()
|
||||
controller.restoreInitial {
|
||||
var snapshot = dataSource.snapshot()
|
||||
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)
|
||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
||||
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
|
||||
let index = existingStatuses.firstIndex(of: centerID),
|
||||
if let centerID = state.centerStatusID,
|
||||
let index = statusIDs.firstIndex(of: centerID),
|
||||
let indexPath = self.dataSource.indexPath(for: items[index]) {
|
||||
// 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
|
||||
|
@ -400,6 +399,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
return
|
||||
}
|
||||
disappearedAt = Date()
|
||||
saveState()
|
||||
}
|
||||
|
||||
@objc func refresh() {
|
||||
|
|
|
@ -22,12 +22,15 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
|||
|
||||
let home = TimelineViewController(for: .home, mastodonController: mastodonController)
|
||||
home.title = homeTitle
|
||||
home.persistsState = true
|
||||
|
||||
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
|
||||
federated.title = federatedTitle
|
||||
federated.persistsState = true
|
||||
|
||||
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
|
||||
local.title = localTitle
|
||||
local.persistsState = true
|
||||
|
||||
super.init(pages: [
|
||||
(.home, "Home", home),
|
||||
|
@ -83,9 +86,6 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
|
|||
return
|
||||
}
|
||||
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() {
|
||||
|
|
|
@ -394,6 +394,8 @@ class CustomAlertActionButton: UIControl {
|
|||
self.isContextMenuInteractionEnabled = true
|
||||
self.showsMenuAsPrimaryAction = action.handler == nil
|
||||
}
|
||||
|
||||
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -429,6 +431,17 @@ class CustomAlertActionButton: UIControl {
|
|||
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?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@ class StatusMetaIndicatorsView: UIView {
|
|||
var secondaryAxisAlignment: Alignment = .leading
|
||||
private var images: [UIImageView] = []
|
||||
private var isUsingSingleAxis = false
|
||||
private var statusID: String?
|
||||
|
||||
private var needsSingleAxis: Bool {
|
||||
traitCollection.preferredContentSizeCategory > .extraLarge
|
||||
|
@ -61,6 +62,11 @@ class StatusMetaIndicatorsView: UIView {
|
|||
}
|
||||
|
||||
func updateUI(status: StatusMO) {
|
||||
guard statusID != status.id else {
|
||||
return
|
||||
}
|
||||
statusID = status.id
|
||||
|
||||
var images: [UIImage] = []
|
||||
|
||||
if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil {
|
||||
|
|
Loading…
Reference in New Issue