Compare commits
31 Commits
13d649bace
...
5ee140cdab
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 5ee140cdab | |
Shadowfacts | ff4dff1147 | |
Shadowfacts | ba1eed7a85 | |
Shadowfacts | 0c9f6e02bd | |
Shadowfacts | 565d17970f | |
Shadowfacts | dc3c2d027c | |
Shadowfacts | ba2c34fdd6 | |
Shadowfacts | 3691c3f483 | |
Shadowfacts | 9c103103e8 | |
Shadowfacts | 382d8ef2c8 | |
Shadowfacts | 2891f47cb3 | |
Shadowfacts | 3c80ec8b43 | |
Shadowfacts | 478ba3db28 | |
Shadowfacts | f96cd1b5e2 | |
Shadowfacts | 7f4ab57a1d | |
Shadowfacts | 8caf93bf0a | |
Shadowfacts | 9c4b68b09e | |
Shadowfacts | b49e8d0279 | |
Shadowfacts | 71a57e9859 | |
Shadowfacts | 081ef16e5e | |
Shadowfacts | b3ec259ce9 | |
Shadowfacts | 4f48514d1a | |
Shadowfacts | f96acd33f2 | |
Shadowfacts | cde061c77a | |
Shadowfacts | a79b3cfd70 | |
Shadowfacts | 9a35f96c75 | |
Shadowfacts | 60767c6a7e | |
Shadowfacts | 57668886b2 | |
Shadowfacts | ffb5c76f7c | |
Shadowfacts | 00e8dd6345 | |
Shadowfacts | 7904462920 |
28
CHANGELOG.md
28
CHANGELOG.md
|
@ -1,5 +1,33 @@
|
|||
# Changelog
|
||||
|
||||
## 2022.1 (52)
|
||||
Features/Improvements:
|
||||
- Save and restore position for all timelines and all accounts
|
||||
- As a side effect of this change, the first time you launch with this update, the timeline position will be lost
|
||||
- Add preference to never blur attachments
|
||||
- New segmented control for timeline switcher that scrolls when there's not enough space
|
||||
- Copy status expand all setting when viewing More Replies in a converstaion
|
||||
- Include followed hashtags in the Explore screen and iPad sidebar
|
||||
- Allow saving or following hashtag from Add Hashtag screen
|
||||
- Scroll long attachment descriptions in the gallery
|
||||
- VoiceOver: Improve Profile Directory
|
||||
- VoiceOver: Include attachment descriptions in timeline items when not marked as sensitive
|
||||
|
||||
Bugfixes:
|
||||
- Fix swipe actions preference not persisting
|
||||
- Fix rich text list bullets/numbers appearing black in dark mode
|
||||
- Fix crash when previewing non-HTTP(S) link
|
||||
- Fix images from Safari pasting as URLs rather than attachments
|
||||
- Fix Trending Posts appearing to reload forever
|
||||
- Fix controls reappearing when swiping between pages in the attachments gallery
|
||||
- Fix reblog confirmation alert actions missing hover effect
|
||||
- Fix status reply/visibility/local icons flashing blue when expanding a status
|
||||
- iPad: Fix My Profile item sidebar item not updating when avatar style changes
|
||||
- iPad: Fix tapping status bar not scrolling to top in single-column navigation mode
|
||||
- VoiceOver: Fix crash when scrolling through local/federated timeline
|
||||
- VoiceOver: Fix escape gesture not working in attachment gallery
|
||||
- VoiceOver: Fix custom emoji not being stripped from display names
|
||||
|
||||
## 2022.1 (51)
|
||||
Features/Improvements:
|
||||
- Clarify text for conversation main status favorite/reblog count preference
|
||||
|
|
|
@ -304,6 +304,8 @@
|
|||
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
|
||||
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 */; };
|
||||
|
@ -559,7 +561,7 @@
|
|||
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; };
|
||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewController.swift; sourceTree = "<group>"; };
|
||||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
|
||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
|
||||
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; };
|
||||
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
||||
D677284924ECBDF400C732D3 /* ComposeCurrentAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeCurrentAccount.swift; sourceTree = "<group>"; };
|
||||
D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
|
||||
|
@ -696,6 +698,8 @@
|
|||
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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>"; };
|
||||
|
@ -931,6 +935,7 @@
|
|||
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
|
||||
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
|
||||
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
|
||||
D6D706A62948D4D0000827ED /* TimlineState.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||
);
|
||||
|
@ -1364,6 +1369,7 @@
|
|||
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
|
||||
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
|
||||
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
|
||||
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
|
||||
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */,
|
||||
04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
|
||||
|
@ -1409,7 +1415,6 @@
|
|||
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
|
||||
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
|
||||
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
|
||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
||||
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
|
||||
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
|
||||
D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
|
||||
|
@ -1483,6 +1488,7 @@
|
|||
D6F1F84E2193B9BE00F5FE67 /* Caching */,
|
||||
D6370B9924421FE00092A7FF /* CoreData */,
|
||||
D667E5F62135C2ED0057A976 /* Extensions */,
|
||||
D6D706A12947D954000827ED /* Gestures */,
|
||||
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
|
||||
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
|
||||
D61959D2241E846D00A37B8E /* Models */,
|
||||
|
@ -1523,6 +1529,14 @@
|
|||
path = TuskerUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6D706A12947D954000827ED /* Gestures */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
|
||||
);
|
||||
path = Gestures;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2035,6 +2049,7 @@
|
|||
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
|
||||
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
|
||||
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
|
||||
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
|
||||
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
|
||||
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
|
||||
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
|
||||
|
@ -2059,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 */,
|
||||
|
@ -2291,7 +2307,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2359,7 +2375,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2509,7 +2525,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2538,7 +2554,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2648,7 +2664,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
@ -2675,7 +2691,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 51;
|
||||
CURRENT_PROJECT_VERSION = 52;
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
|
|
|
@ -257,8 +257,8 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
|
|||
]
|
||||
if let nodeInfo {
|
||||
crumb.data!["nodeInfo"] = [
|
||||
"version": nodeInfo.version,
|
||||
"software": nodeInfo.software,
|
||||
"software": nodeInfo.software.name,
|
||||
"version": nodeInfo.software.version,
|
||||
]
|
||||
}
|
||||
SentrySDK.addBreadcrumb(crumb: crumb)
|
||||
|
|
|
@ -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>
|
|
@ -20,18 +20,30 @@ extension AccountMO {
|
|||
}
|
||||
|
||||
var displayNameWithoutCustomEmoji: String {
|
||||
if displayName.isEmpty {
|
||||
let stripped = stripCustomEmoji(from: displayName)
|
||||
if stripped.isEmpty {
|
||||
return username
|
||||
} else {
|
||||
return stripCustomEmoji(from: displayName)
|
||||
return stripped
|
||||
}
|
||||
}
|
||||
|
||||
private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
|
||||
}
|
||||
|
||||
extension Account {
|
||||
var displayNameWithoutCustomEmoji: String {
|
||||
let stripped = stripCustomEmoji(from: displayName)
|
||||
if stripped.isEmpty {
|
||||
return username
|
||||
} else {
|
||||
return stripped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
|
||||
|
||||
private func stripCustomEmoji(from string: String) -> String {
|
||||
let range = NSRange(location: 0, length: string.utf16.count)
|
||||
return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||
}
|
||||
|
||||
return customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
|
||||
}
|
||||
|
|
|
@ -106,9 +106,9 @@ struct HTMLConverter {
|
|||
let index = (try? node.elementSiblingIndex()) ?? 0
|
||||
// we use the monospace digit font so that the periods of all the list items line up
|
||||
// TODO: this probably breaks with dynamic type
|
||||
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular)])
|
||||
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular), .foregroundColor: color])
|
||||
} else if parentTag == "ul" {
|
||||
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font])
|
||||
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font, .foregroundColor: color])
|
||||
} else {
|
||||
bullet = NSAttributedString()
|
||||
}
|
||||
|
|
|
@ -43,6 +43,8 @@ class Preferences: Codable, ObservableObject {
|
|||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||
|
||||
self.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
|
||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||
|
@ -52,7 +54,11 @@ class Preferences: Codable, ObservableObject {
|
|||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||
|
||||
self.blurAllMedia = try container.decode(Bool.self, forKey: .blurAllMedia)
|
||||
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||
} else {
|
||||
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||
}
|
||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
|
||||
|
@ -86,6 +92,8 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
||||
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||
|
||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||
|
@ -95,7 +103,7 @@ class Preferences: Codable, ObservableObject {
|
|||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
||||
|
||||
try container.encode(blurAllMedia, forKey: .blurAllMedia)
|
||||
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
|
||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||
|
||||
|
@ -140,10 +148,12 @@ class Preferences: Codable, ObservableObject {
|
|||
@Published var useTwitterKeyboard = false
|
||||
|
||||
// MARK: Media
|
||||
@Published var blurAllMedia = false {
|
||||
@Published var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
|
||||
didSet {
|
||||
if blurAllMedia {
|
||||
if attachmentBlurMode == .always {
|
||||
blurMediaBehindContentWarning = true
|
||||
} else if attachmentBlurMode == .never {
|
||||
blurMediaBehindContentWarning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -182,6 +192,8 @@ class Preferences: Codable, ObservableObject {
|
|||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
case hideActionsInTimeline
|
||||
case leadingStatusSwipeActions
|
||||
case trailingStatusSwipeActions
|
||||
|
||||
case defaultPostVisibility
|
||||
case defaultReplyVisibility
|
||||
|
@ -191,7 +203,8 @@ class Preferences: Codable, ObservableObject {
|
|||
case mentionReblogger
|
||||
case useTwitterKeyboard
|
||||
|
||||
case blurAllMedia
|
||||
case blurAllMedia // only used for migration
|
||||
case attachmentBlurMode
|
||||
case blurMediaBehindContentWarning
|
||||
case automaticallyPlayGifs
|
||||
|
||||
|
@ -254,4 +267,23 @@ extension Preferences {
|
|||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -137,6 +137,11 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
|
|||
}
|
||||
}
|
||||
|
||||
override func accessibilityPerformEscape() -> Bool {
|
||||
dismiss(animated: true)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Page View Controller Data Source
|
||||
|
||||
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
|
|
|
@ -16,6 +16,7 @@ protocol ComposeUIStateDelegate: AnyObject {
|
|||
func presentAssetPickerSheet()
|
||||
func presentComposeDrawing()
|
||||
func selectDraft(_ draft: Draft)
|
||||
func paste(itemProviders: [NSItemProvider])
|
||||
}
|
||||
|
||||
class ComposeUIState: ObservableObject {
|
||||
|
|
|
@ -72,8 +72,24 @@ struct ComposeView: View {
|
|||
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) }
|
||||
}
|
||||
|
||||
private var validAttachmentCombination: Bool {
|
||||
if !mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
|
||||
return true
|
||||
} else if draft.attachments.contains(where: { $0.data.type == .video }) && draft.attachments.count > 1 {
|
||||
return false
|
||||
} else if draft.attachments.count > 4 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private var postButtonEnabled: Bool {
|
||||
draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
||||
draft.hasContent
|
||||
&& charactersRemaining >= 0
|
||||
&& !isPosting
|
||||
&& !requiresAttachmentDescriptions
|
||||
&& validAttachmentCombination
|
||||
&& (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
|
|
@ -83,7 +83,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
@Environment(\.isEnabled) var isEnabled: Bool
|
||||
|
||||
func makeUIView(context: Context) -> UITextView {
|
||||
let textView = WrappedTextView()
|
||||
let textView = WrappedTextView(uiState: uiState)
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = .clear
|
||||
|
@ -128,6 +128,16 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
|
||||
class WrappedTextView: UITextView {
|
||||
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))]
|
||||
unowned var uiState: ComposeUIState
|
||||
|
||||
init(uiState: ComposeUIState) {
|
||||
self.uiState = uiState
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if formattingActions.contains(action) {
|
||||
|
@ -154,6 +164,14 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
if UIPasteboard.general.contains(pasteboardTypes: CompositionAttachment.readableTypeIdentifiersForItemProvider) {
|
||||
uiState.delegate?.paste(itemProviders: UIPasteboard.general.itemProviders)
|
||||
} else {
|
||||
super.paste(sender)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {
|
||||
|
|
|
@ -354,6 +354,7 @@ class ConversationTableViewController: EnhancedTableViewController {
|
|||
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
||||
let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
|
||||
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
|
||||
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||
show(conv)
|
||||
} else {
|
||||
super.tableView(tableView, didSelectRowAt: indexPath)
|
||||
|
|
|
@ -84,7 +84,7 @@ class AddSavedHashtagViewController: UIViewController {
|
|||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonPressed))
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -107,15 +107,12 @@ class AddSavedHashtagViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func selectHashtag(_ hashtag: Hashtag) {
|
||||
let context = mastodonController.persistentContainer.viewContext
|
||||
_ = SavedHashtag(hashtag: hashtag, context: context)
|
||||
try! context.save()
|
||||
presentingViewController!.dismiss(animated: true)
|
||||
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil)
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc func cancelButtonPressed() {
|
||||
@objc func doneButtonPressed() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
|
@ -128,11 +125,6 @@ extension AddSavedHashtagViewController {
|
|||
enum Item: Hashable {
|
||||
case tag(Hashtag)
|
||||
}
|
||||
// class DataSource: UITableViewDiffableDataSource<Section, Item> {
|
||||
// override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
extension AddSavedHashtagViewController: UICollectionViewDelegate {
|
||||
|
|
|
@ -24,7 +24,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
|
||||
var searchControllerStatusOnAppearance: Bool? = nil
|
||||
|
||||
private var listsCancellable: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
@ -70,12 +70,26 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
navigationItem.searchController = searchController
|
||||
navigationItem.hidesSearchBarWhenScrolling = false
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
||||
listsCancellable = mastodonController.$lists
|
||||
mastodonController.$lists
|
||||
.sink { [unowned self] in self.reloadLists($0) }
|
||||
.store(in: &cancellables)
|
||||
mastodonController.$followedHashtags
|
||||
.merge(with:
|
||||
NotificationCenter.default.publisher(for: .savedHashtagsChanged)
|
||||
.map { [unowned self] _ in self.mastodonController.followedHashtags }
|
||||
)
|
||||
.sink { [unowned self] in self.updateHashtagsSection(followed: $0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
let a = PassthroughSubject<Int, Never>()
|
||||
let b = PassthroughSubject<Int, Never>()
|
||||
|
||||
a.merge(with: b)
|
||||
.sink(receiveValue: { print($0) })
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
|
@ -149,9 +163,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
addDiscoverSection(to: &snapshot)
|
||||
}
|
||||
snapshot.appendItems([.addList], toSection: .lists)
|
||||
let hashtags = fetchSavedHashtags().map {
|
||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||
}
|
||||
let hashtags = fetchHashtagItems(followed: mastodonController.followedHashtags)
|
||||
snapshot.appendItems(hashtags, toSection: .savedHashtags)
|
||||
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
|
||||
let instances = fetchSavedInstances().map {
|
||||
|
@ -193,14 +205,16 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
||||
let req = SavedHashtag.fetchRequest()
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
|
||||
do {
|
||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
||||
} catch {
|
||||
return []
|
||||
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
||||
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
|
||||
var items = saved.map {
|
||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||
}
|
||||
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
||||
}
|
||||
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.label))
|
||||
return items
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
@ -214,12 +228,10 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func savedHashtagsChanged() {
|
||||
private func updateHashtagsSection(followed: [FollowedHashtag]) {
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
|
||||
let hashtags = fetchSavedHashtags().map {
|
||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||
}
|
||||
let hashtags = fetchHashtagItems(followed: followed)
|
||||
snapshot.appendItems(hashtags, toSection: .savedHashtags)
|
||||
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
|
||||
dataSource.apply(snapshot)
|
||||
|
@ -386,7 +398,7 @@ extension ExploreViewController {
|
|||
case .lists:
|
||||
return NSLocalizedString("Lists", comment: "explore lists section title")
|
||||
case .savedHashtags:
|
||||
return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title")
|
||||
return NSLocalizedString("Hashtags", comment: "explore saved hashtags section title")
|
||||
case .savedInstances:
|
||||
return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title")
|
||||
}
|
||||
|
@ -425,7 +437,7 @@ extension ExploreViewController {
|
|||
case let .savedHashtag(hashtag):
|
||||
return hashtag.name
|
||||
case .addSavedHashtag:
|
||||
return NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title")
|
||||
return NSLocalizedString("Add Hashtag...", comment: "save hashtag nav item title")
|
||||
case let .savedInstance(url):
|
||||
return url.host!
|
||||
case .findInstance:
|
||||
|
|
|
@ -123,4 +123,23 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: Accessibility
|
||||
|
||||
override var isAccessibilityElement: Bool {
|
||||
get { true }
|
||||
set {}
|
||||
}
|
||||
|
||||
override var accessibilityAttributedLabel: NSAttributedString? {
|
||||
get {
|
||||
guard let account else {
|
||||
return nil
|
||||
}
|
||||
let s = NSMutableAttributedString(string: "\(account.displayNameWithoutCustomEmoji), ")
|
||||
s.append(noteTextView.attributedText)
|
||||
return s
|
||||
}
|
||||
set {}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -34,8 +34,9 @@ class ProfileDirectoryViewController: UIViewController {
|
|||
|
||||
title = NSLocalizedString("Profile Directory", comment: "profile directory title")
|
||||
|
||||
// todo: it would be nice if there were a better "filter" icon
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "scope"), menu: nil)
|
||||
let filterItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), menu: nil)
|
||||
filterItem.accessibilityLabel = "Filter"
|
||||
navigationItem.rightBarButtonItem = filterItem
|
||||
updateFilterMenu()
|
||||
|
||||
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in
|
||||
|
|
|
@ -85,14 +85,14 @@ class TrendingStatusesViewController: UIViewController {
|
|||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if !loaded {
|
||||
loaded = true
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems([.loadingIndicator])
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
||||
Task {
|
||||
if !loaded {
|
||||
loaded = true
|
||||
await loadTrendingStatuses()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,10 +17,10 @@ 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!
|
||||
private var shareImage: UIImageView!
|
||||
private var shareButtonTopConstraint: NSLayoutConstraint!
|
||||
private var shareButtonLeadingConstraint: NSLayoutConstraint!
|
||||
|
@ -46,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?
|
||||
|
@ -98,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 {
|
||||
|
@ -116,6 +123,12 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
view.addGestureRecognizer(doubleTap)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
||||
accessibilityElements = [
|
||||
topControlsView!,
|
||||
contentView,
|
||||
descriptionTextView!,
|
||||
]
|
||||
}
|
||||
|
||||
private func setupContentView() {
|
||||
|
@ -135,6 +148,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
private func setupControls() {
|
||||
shareContainer = UIView()
|
||||
shareContainer.isAccessibilityElement = true
|
||||
shareContainer.accessibilityTraits = .button
|
||||
shareContainer.accessibilityLabel = "Share"
|
||||
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
|
||||
shareContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
topControlsView.addSubview(shareContainer)
|
||||
|
@ -161,7 +177,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
shareImage.heightAnchor.constraint(equalToConstant: 24),
|
||||
])
|
||||
|
||||
let closeContainer = UIView()
|
||||
closeContainer = UIView()
|
||||
closeContainer.isAccessibilityElement = true
|
||||
closeContainer.accessibilityTraits = .button
|
||||
closeContainer.accessibilityLabel = "Close"
|
||||
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
|
||||
closeContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
topControlsView.addSubview(closeContainer)
|
||||
|
@ -198,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()
|
||||
|
||||
|
@ -230,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
|
||||
|
@ -244,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -264,11 +308,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
|
|||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
|
||||
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>
|
||||
|
|
|
@ -80,7 +80,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
|
|||
return
|
||||
}
|
||||
config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
|
||||
self.contentConfiguration = contentConfiguration
|
||||
self.contentConfiguration = config
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ class MainSidebarViewController: UIViewController {
|
|||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
|
||||
private var listsCancellable: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var allItems: [Item] {
|
||||
[
|
||||
|
@ -101,12 +101,19 @@ class MainSidebarViewController: UIViewController {
|
|||
|
||||
select(item: .tab(.timelines), animated: false)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
||||
listsCancellable = mastodonController.$lists
|
||||
mastodonController.$lists
|
||||
.sink { [unowned self] in self.reloadLists($0) }
|
||||
.store(in: &cancellables)
|
||||
mastodonController.$followedHashtags
|
||||
.merge(with:
|
||||
NotificationCenter.default.publisher(for: .savedHashtagsChanged)
|
||||
.map { [unowned self] _ in self.mastodonController.followedHashtags }
|
||||
)
|
||||
.sink { [unowned self] in self.updateHashtagsSection(followed: $0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
onViewDidLoad?()
|
||||
}
|
||||
|
@ -176,7 +183,7 @@ class MainSidebarViewController: UIViewController {
|
|||
|
||||
applyDiscoverSectionSnapshot()
|
||||
reloadLists(mastodonController.lists)
|
||||
reloadSavedHashtags()
|
||||
updateHashtagsSection(followed: mastodonController.followedHashtags)
|
||||
reloadSavedInstances()
|
||||
}
|
||||
|
||||
|
@ -224,14 +231,16 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
private func fetchSavedHashtags() -> [SavedHashtag] {
|
||||
let req = SavedHashtag.fetchRequest()
|
||||
req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
|
||||
do {
|
||||
return try mastodonController.persistentContainer.viewContext.fetch(req)
|
||||
} catch {
|
||||
return []
|
||||
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] {
|
||||
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? []
|
||||
var items = saved.map {
|
||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||
}
|
||||
for followed in followed where !saved.contains(where: { $0.name == followed.name }) {
|
||||
items.append(.savedHashtag(Hashtag(name: followed.name, url: followed.url)))
|
||||
}
|
||||
items.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
|
||||
return items
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
@ -245,10 +254,8 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
@objc private func reloadSavedHashtags() {
|
||||
let hashtags = fetchSavedHashtags().map {
|
||||
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
|
||||
}
|
||||
private func updateHashtagsSection(followed: [FollowedHashtag]) {
|
||||
let hashtags = fetchHashtagItems(followed: followed)
|
||||
if let selectedItem,
|
||||
case .savedHashtag(_) = selectedItem,
|
||||
!hashtags.contains(selectedItem) {
|
||||
|
@ -403,13 +410,13 @@ extension MainSidebarViewController {
|
|||
case .addList:
|
||||
return "New List..."
|
||||
case .savedHashtagsHeader:
|
||||
return "Saved Hashtags"
|
||||
return "Hashtags"
|
||||
case let .savedHashtag(hashtag):
|
||||
return hashtag.name
|
||||
case .addSavedHashtag:
|
||||
return "Save Hashtag..."
|
||||
return "Add Hashtag..."
|
||||
case .savedInstancesHeader:
|
||||
return "Saved Instances"
|
||||
return "Instance Timelines"
|
||||
case let .savedInstance(url):
|
||||
return url.host!
|
||||
case .addSavedInstance:
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
class NotificationsPageViewController: SegmentedPageViewController {
|
||||
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> {
|
||||
|
||||
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
|
||||
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
|
||||
|
@ -30,12 +30,9 @@ class NotificationsPageViewController: SegmentedPageViewController {
|
|||
mentions.title = mentionsTitle
|
||||
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
|
||||
|
||||
super.init(titles: [
|
||||
notificationsTitle,
|
||||
mentionsTitle
|
||||
], pageControllers: [
|
||||
notifications,
|
||||
mentions
|
||||
super.init(pages: [
|
||||
(.all, notificationsTitle, notifications),
|
||||
(.mentions, mentionsTitle, mentions),
|
||||
])
|
||||
|
||||
title = notificationsTitle
|
||||
|
@ -53,15 +50,20 @@ class NotificationsPageViewController: SegmentedPageViewController {
|
|||
}
|
||||
|
||||
func selectMode(_ mode: NotificationsMode) {
|
||||
let index: Int
|
||||
let page: Page
|
||||
switch mode {
|
||||
case .allNotifications:
|
||||
index = 0
|
||||
page = .all
|
||||
case .mentionsOnly:
|
||||
index = 1
|
||||
page = .mentions
|
||||
}
|
||||
segmentedControl.selectedSegmentIndex = index
|
||||
selectPage(at: index, animated: false)
|
||||
segmentedControl.setSelectedOption(page, animated: false)
|
||||
selectPage(page, animated: false)
|
||||
}
|
||||
|
||||
enum Page {
|
||||
case all
|
||||
case mentions
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -21,14 +21,18 @@ struct MediaPrefsView: View {
|
|||
|
||||
var viewingSection: some View {
|
||||
Section(header: Text("Viewing")) {
|
||||
Toggle(isOn: $preferences.blurAllMedia) {
|
||||
Text("Blur All Media")
|
||||
Picker(selection: $preferences.attachmentBlurMode) {
|
||||
ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
} label: {
|
||||
Text("Blur Media")
|
||||
}
|
||||
|
||||
Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
|
||||
Text("Blur Media Behind Content Warning")
|
||||
}
|
||||
.disabled(preferences.blurAllMedia)
|
||||
.disabled(preferences.attachmentBlurMode != .useStatusSetting)
|
||||
|
||||
Toggle(isOn: $preferences.automaticallyPlayGifs) {
|
||||
Text("Automatically Play GIFs")
|
||||
|
|
|
@ -142,7 +142,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
let view = ProfileHeaderView.create()
|
||||
view.delegate = self.profileHeaderDelegate
|
||||
view.updateUI(for: id)
|
||||
view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
|
||||
view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false)
|
||||
cell.addHeader(view)
|
||||
case .useExistingView(let view):
|
||||
cell.addHeader(view)
|
||||
|
|
|
@ -29,7 +29,11 @@ class ProfileViewController: UIViewController {
|
|||
}
|
||||
|
||||
private(set) var currentIndex: Int!
|
||||
private let pages = [Page.posts, .postsAndReplies, .media]
|
||||
private var pageControllers: [ProfileStatusesViewController]!
|
||||
var currentPage: Page {
|
||||
pages[currentIndex]
|
||||
}
|
||||
var currentViewController: ProfileStatusesViewController {
|
||||
pageControllers[currentIndex]
|
||||
}
|
||||
|
@ -283,6 +287,14 @@ class ProfileViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
enum Page: Hashable {
|
||||
case posts
|
||||
case postsAndReplies
|
||||
case media
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController {
|
||||
enum State {
|
||||
case idle
|
||||
|
@ -298,24 +310,25 @@ extension ProfileViewController: ToastableViewController {
|
|||
}
|
||||
|
||||
extension ProfileViewController: ProfileHeaderViewDelegate {
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) {
|
||||
guard case .idle = state else {
|
||||
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
|
||||
return
|
||||
}
|
||||
selectPage(at: newIndex, animated: true)
|
||||
selectPage(at: pages.firstIndex(of: newPage)!, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProfileViewController: TabbedPageViewController {
|
||||
func selectNextPage() {
|
||||
guard currentIndex < pageControllers.count - 1 else { return }
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true)
|
||||
selectPage(at: currentIndex + 1, animated: true)
|
||||
}
|
||||
|
||||
func selectPrevPage() {
|
||||
guard currentIndex > 0 else { return }
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
|
||||
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true)
|
||||
selectPage(at: currentIndex - 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,35 +285,31 @@ 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()
|
||||
|
@ -318,7 +317,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
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,
|
||||
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
|
||||
|
@ -345,7 +344,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
|
||||
private func removeTimelineDescriptionCell() {
|
||||
var snapshot = dataSource.snapshot()
|
||||
snapshot.deleteSections([.header])
|
||||
snapshot.deleteItems([.publicTimelineDescription])
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
isShowingTimelineDescription = false
|
||||
}
|
||||
|
@ -400,6 +399,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
return
|
||||
}
|
||||
disappearedAt = Date()
|
||||
saveState()
|
||||
}
|
||||
|
||||
@objc func refresh() {
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
class TimelinesPageViewController: SegmentedPageViewController {
|
||||
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> {
|
||||
|
||||
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
|
||||
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
|
||||
|
@ -22,21 +22,20 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
|||
|
||||
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(titles: [
|
||||
homeTitle,
|
||||
federatedTitle,
|
||||
localTitle
|
||||
], pageControllers: [
|
||||
home,
|
||||
federated,
|
||||
local
|
||||
super.init(pages: [
|
||||
(.home, "Home", home),
|
||||
(.local, "Local", local),
|
||||
(.federated, "Federated", federated),
|
||||
])
|
||||
|
||||
title = homeTitle
|
||||
|
@ -75,24 +74,28 @@ class TimelinesPageViewController: SegmentedPageViewController {
|
|||
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
|
||||
return
|
||||
}
|
||||
let index: Int
|
||||
let page: Page
|
||||
switch timeline {
|
||||
case .home:
|
||||
index = 0
|
||||
page = .home
|
||||
case .public(local: false):
|
||||
index = 1
|
||||
page = .federated
|
||||
case .public(local: true):
|
||||
index = 2
|
||||
page = .local
|
||||
default:
|
||||
return
|
||||
}
|
||||
selectPage(at: index, animated: false)
|
||||
let timelineVC = pageControllers[index] as! TimelineViewController
|
||||
timelineVC.restoreActivity(activity)
|
||||
selectPage(page, animated: false)
|
||||
}
|
||||
|
||||
@objc private func filtersPressed() {
|
||||
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
|
||||
}
|
||||
|
||||
enum Page: Hashable {
|
||||
case home
|
||||
case local
|
||||
case federated
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -8,33 +8,45 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate {
|
||||
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController {
|
||||
|
||||
let titles: [String]
|
||||
let pages: [Page]
|
||||
let pageControllers: [UIViewController]
|
||||
|
||||
private var initialIndex = 0
|
||||
private(set) var currentIndex = 0
|
||||
private var initialPage: Page
|
||||
private var currentPage: Page
|
||||
var currentIndex: Int {
|
||||
pages.firstIndex(of: currentPage)!
|
||||
}
|
||||
|
||||
var segmentedControl: UISegmentedControl!
|
||||
let segmentedControl = ScrollingSegmentedControl<Page>()
|
||||
|
||||
init(titles: [String], pageControllers: [UIViewController]) {
|
||||
precondition(!pageControllers.isEmpty)
|
||||
init(pages: [(Page, String, UIViewController)]) {
|
||||
precondition(!pages.isEmpty)
|
||||
|
||||
self.titles = titles
|
||||
self.pageControllers = pageControllers
|
||||
self.pages = pages.map(\.0)
|
||||
self.pageControllers = pages.map(\.2)
|
||||
|
||||
initialPage = self.pages.first!
|
||||
currentPage = self.pages.first!
|
||||
|
||||
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
|
||||
self.delegate = self
|
||||
|
||||
// this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
|
||||
// before the view has necessarily loaded
|
||||
segmentedControl = UISegmentedControl(items: titles)
|
||||
segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
|
||||
segmentedControl.options = pages.map {
|
||||
.init(value: $0.0, name: $0.1)
|
||||
}
|
||||
segmentedControl.didSelectOption = { [unowned self] option in
|
||||
if let option {
|
||||
self.selectPage(option, animated: true)
|
||||
}
|
||||
}
|
||||
// TODO: the custom segmented control isn't treated as a group and I have no idea how to change that
|
||||
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
||||
// so make it clear that to switch tabs the user needs to enter the group
|
||||
segmentedControl.accessibilityHint = "Enter group to select timeline"
|
||||
segmentedControl.setSelectedOption(segmentedControl.options.first!.value, animated: false)
|
||||
navigationItem.titleView = segmentedControl
|
||||
}
|
||||
|
||||
|
@ -47,7 +59,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
|
||||
view.backgroundColor = .systemBackground
|
||||
|
||||
selectPage(at: initialIndex, animated: false)
|
||||
selectPage(initialPage, animated: false)
|
||||
|
||||
addKeyCommand(MenuController.prevSubTabCommand)
|
||||
addKeyCommand(MenuController.nextSubTabCommand)
|
||||
|
@ -60,28 +72,36 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
|
|||
}
|
||||
}
|
||||
|
||||
func selectPage(at index: Int, animated: Bool) {
|
||||
func selectPage(_ page: Page, animated: Bool) {
|
||||
guard pages.contains(page) else {
|
||||
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
|
||||
}
|
||||
guard isViewLoaded else {
|
||||
initialIndex = index
|
||||
initialPage = page
|
||||
return
|
||||
}
|
||||
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
|
||||
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
|
||||
navigationItem.title = pageControllers[index].title
|
||||
currentIndex = index
|
||||
segmentedControl.selectedSegmentIndex = index
|
||||
let prevIndex = currentIndex
|
||||
currentPage = page
|
||||
let index = pages.firstIndex(of: page)!
|
||||
let newController = pageControllers[index]
|
||||
|
||||
let direction: UIPageViewController.NavigationDirection = index - prevIndex > 0 ? .forward : .reverse
|
||||
setViewControllers([newController], direction: direction, animated: animated)
|
||||
navigationItem.title = newController.title
|
||||
|
||||
segmentedControl.setSelectedOption(page, animated: animated)
|
||||
}
|
||||
|
||||
@objc func segmentedControlChanged() {
|
||||
selectPage(at: segmentedControl.selectedSegmentIndex, animated: true)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
// MARK: TabbedPageViewController
|
||||
|
||||
func selectNextPage() {
|
||||
guard currentIndex < pageControllers.count - 1 else { return }
|
||||
selectPage(pages[currentIndex + 1], animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Page View Controller Delegate
|
||||
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||
currentIndex = pageControllers.firstIndex(of: viewControllers!.first!)!
|
||||
segmentedControl.selectedSegmentIndex = currentIndex
|
||||
navigationItem.title = viewControllers!.first!.title
|
||||
func selectPrevPage() {
|
||||
guard currentIndex > 0 else { return }
|
||||
selectPage(pages[currentIndex - 1], animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -94,18 +114,6 @@ extension SegmentedPageViewController: TabBarScrollableViewController {
|
|||
}
|
||||
}
|
||||
|
||||
extension SegmentedPageViewController: TabbedPageViewController {
|
||||
func selectNextPage() {
|
||||
guard currentIndex < pageControllers.count - 1 else { return }
|
||||
selectPage(at: currentIndex + 1, animated: true)
|
||||
}
|
||||
|
||||
func selectPrevPage() {
|
||||
guard currentIndex > 0 else { return }
|
||||
selectPage(at: currentIndex - 1, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension SegmentedPageViewController: BackgroundableViewController {
|
||||
func sceneDidEnterBackground() {
|
||||
if let current = pageControllers[currentIndex] as? BackgroundableViewController {
|
||||
|
|
|
@ -245,7 +245,7 @@ extension SplitNavigationController: StatusBarTappableViewController {
|
|||
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
||||
let vcs = viewControllers
|
||||
if !canShowSecondaryNav || vcs.count < 2 {
|
||||
return (vcs.first! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
||||
return (vcs.last! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
|
||||
} else {
|
||||
let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
|
||||
let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
|
||||
|
|
|
@ -217,20 +217,19 @@ class UserActivityManager {
|
|||
switch timeline {
|
||||
case .home, .public(true), .public(false):
|
||||
navigationController.popToRootViewController(animated: false)
|
||||
let rootController = navigationController.viewControllers.first! as! SegmentedPageViewController
|
||||
let index: Int
|
||||
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
|
||||
let page: TimelinesPageViewController.Page
|
||||
switch timeline {
|
||||
case .home:
|
||||
index = 0
|
||||
case .public(false):
|
||||
index = 1
|
||||
case .public(true):
|
||||
index = 2
|
||||
page = .home
|
||||
case .public(local: false):
|
||||
page = .federated
|
||||
case .public(local: true):
|
||||
page = .local
|
||||
default:
|
||||
fatalError()
|
||||
}
|
||||
rootController.segmentedControl.selectedSegmentIndex = index
|
||||
rootController.selectPage(at: index, animated: false)
|
||||
rootController.selectPage(page, animated: false)
|
||||
default:
|
||||
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||
navigationController.pushViewController(timeline, animated: false)
|
||||
|
|
|
@ -94,7 +94,7 @@ class AccountCollectionViewCell: UICollectionViewListCell {
|
|||
let account = mastodonController.persistentContainer.account(for: accountID) else {
|
||||
return nil
|
||||
}
|
||||
var str = AttributedString(account.displayOrUserName)
|
||||
var str = AttributedString(account.displayNameWithoutCustomEmoji)
|
||||
str += ", @"
|
||||
str += AttributedString(account.acct)
|
||||
return NSAttributedString(str)
|
||||
|
|
|
@ -173,15 +173,17 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
|
||||
// MARK: - Navigation
|
||||
|
||||
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController {
|
||||
func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController? {
|
||||
let text = (self.text as NSString).substring(with: range)
|
||||
|
||||
if let mention = getMention(for: url, text: text) {
|
||||
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
|
||||
} else if let tag = getHashtag(for: url, text: text) {
|
||||
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
|
||||
} else {
|
||||
} else if url.scheme == "https" || url.scheme == "http" {
|
||||
return SFSafariViewController(url: url)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import Pachyderm
|
|||
import Combine
|
||||
|
||||
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
|
||||
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page)
|
||||
}
|
||||
|
||||
class ProfileHeaderView: UIView {
|
||||
|
@ -35,10 +35,11 @@ class ProfileHeaderView: UIView {
|
|||
@IBOutlet weak var displayNameLabel: EmojiLabel!
|
||||
@IBOutlet weak var usernameLabel: UILabel!
|
||||
@IBOutlet weak var lockImageView: UIImageView!
|
||||
@IBOutlet weak var vStack: UIStackView!
|
||||
@IBOutlet weak var relationshipLabel: UILabel!
|
||||
@IBOutlet weak var noteTextView: StatusContentTextView!
|
||||
@IBOutlet weak var fieldsView: ProfileFieldsView!
|
||||
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
|
||||
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||
|
||||
var accountID: String!
|
||||
|
||||
|
@ -83,6 +84,22 @@ class ProfileHeaderView: UIView {
|
|||
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
|
||||
noteTextView.adjustsFontForContentSizeCategory = true
|
||||
|
||||
pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero)
|
||||
pagesSegmentedControl.options = [
|
||||
.init(value: .posts, name: "Posts"),
|
||||
.init(value: .postsAndReplies, name: "Posts and Replies"),
|
||||
.init(value: .media, name: "Media"),
|
||||
]
|
||||
pagesSegmentedControl.setSelectedOption(.posts, animated: false)
|
||||
pagesSegmentedControl.didSelectOption = { [unowned self] newPage in
|
||||
if let newPage {
|
||||
self.delegate?.profileHeader(self, selectedPageChangedTo: newPage)
|
||||
}
|
||||
}
|
||||
vStack.addArrangedSubview(pagesSegmentedControl)
|
||||
// equal inset on both sides, the leading inset is applied to the vStack
|
||||
pagesSegmentedControl.widthAnchor.constraint(equalTo: vStack.widthAnchor, constant: -16).isActive = true
|
||||
|
||||
// the segemented control itself is only focusable when VoiceOver is in Group navigation mode,
|
||||
// so make it clear that to switch tabs the user needs to enter the group
|
||||
pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
|
||||
|
@ -264,11 +281,6 @@ class ProfileHeaderView: UIView {
|
|||
delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView)
|
||||
}
|
||||
|
||||
@IBAction func postsSegmentedControlChanged(_ sender: UISegmentedControl) {
|
||||
delegate?.profileHeader(self, selectedPostsIndexChangedTo: sender.selectedSegmentIndex)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ProfileHeaderView: UIPointerInteractionDelegate {
|
||||
|
|
|
@ -69,42 +69,22 @@
|
|||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" horizontalHuggingPriority="249" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" scrollEnabled="NO" delaysContentTouches="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1O8-2P-Gbf" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="382" height="460"/>
|
||||
<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" systemColor="labelColor"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
|
||||
</textView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="267.5" width="398" height="128"/>
|
||||
<rect key="frame" x="0.0" y="468" width="398" height="128"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0">
|
||||
<rect key="frame" x="0.0" y="403.5" width="382" height="185"/>
|
||||
<segments>
|
||||
<segment title="Posts"/>
|
||||
<segment title="Posts and Replies"/>
|
||||
<segment title="Media"/>
|
||||
</segments>
|
||||
<connections>
|
||||
<action selector="postsSegmentedControlChanged:" destination="iN0-l3-epB" eventType="valueChanged" id="D6y-ZM-DwU"/>
|
||||
</connections>
|
||||
</segmentedControl>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
|
||||
<rect key="frame" x="0.0" y="595.5" width="398" height="0.5"/>
|
||||
<color key="backgroundColor" systemColor="separatorColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/>
|
||||
<constraint firstItem="n1M-vM-Cj0" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="9Ds-zl-acc"/>
|
||||
<constraint firstItem="5ja-fK-Fqz" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="azv-le-93y"/>
|
||||
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
|
||||
</constraints>
|
||||
</stackView>
|
||||
|
@ -124,6 +104,13 @@
|
|||
</imageView>
|
||||
</subviews>
|
||||
</stackView>
|
||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
|
||||
<rect key="frame" x="16" y="861.5" width="398" height="0.5"/>
|
||||
<color key="backgroundColor" systemColor="separatorColor"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
|
@ -133,9 +120,11 @@
|
|||
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
|
||||
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
|
||||
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="5ja-fK-Fqz" secondAttribute="bottom" id="9ZS-Ey-eKd"/>
|
||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
|
||||
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
|
||||
<constraint firstAttribute="trailing" secondItem="5ja-fK-Fqz" secondAttribute="trailing" id="EMk-dp-yJV"/>
|
||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
|
||||
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
|
||||
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
|
||||
|
@ -143,6 +132,7 @@
|
|||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
|
||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
|
||||
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
|
||||
<constraint firstItem="5ja-fK-Fqz" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="jPG-WM-9km"/>
|
||||
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
|
||||
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
|
||||
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
|
||||
|
@ -157,9 +147,9 @@
|
|||
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
|
||||
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
|
||||
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
|
||||
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
|
||||
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
|
||||
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
|
||||
<outlet property="vStack" destination="u4P-3i-gEq" id="EUC-d2-cQC"/>
|
||||
</connections>
|
||||
<point key="canvasLocation" x="-590" y="117"/>
|
||||
</view>
|
||||
|
|
|
@ -0,0 +1,228 @@
|
|||
//
|
||||
// ScrollingSegmentedControl.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 12/11/22.
|
||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecognizerDelegate, UIPointerInteractionDelegate {
|
||||
|
||||
private(set) var selectedOption: Value?
|
||||
var options: [Option] = [] {
|
||||
didSet {
|
||||
createOptionViews()
|
||||
}
|
||||
}
|
||||
var didSelectOption: ((Value?) -> Void)?
|
||||
|
||||
private let optionsStack = UIStackView()
|
||||
private let selectedIndicatorView = UIView()
|
||||
private var selectedIndicatorViewAlignmentConstraints: [NSLayoutConstraint] = []
|
||||
private var changeSelectionPanRecognizer: UIGestureRecognizer!
|
||||
private var selectedOptionAtStartOfPan: Value?
|
||||
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let buttonWidths = optionsStack.arrangedSubviews.map(\.intrinsicContentSize.width).reduce(0, +)
|
||||
let spacing = (CGFloat(optionsStack.arrangedSubviews.count) - 1) * 8
|
||||
// add 16 to account for the spacing around optionsStack
|
||||
return CGSize(width: buttonWidths + spacing + 16, height: 44)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
showsHorizontalScrollIndicator = false
|
||||
|
||||
optionsStack.axis = .horizontal
|
||||
optionsStack.spacing = 8
|
||||
optionsStack.distribution = .fillProportionally
|
||||
optionsStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(optionsStack)
|
||||
NSLayoutConstraint.activate([
|
||||
optionsStack.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor, constant: 8),
|
||||
optionsStack.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor, constant: -8),
|
||||
optionsStack.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
|
||||
optionsStack.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
||||
optionsStack.heightAnchor.constraint(equalTo: heightAnchor),
|
||||
|
||||
// add 16 to account for the spacing around optionsStack
|
||||
widthAnchor.constraint(lessThanOrEqualTo: optionsStack.widthAnchor, constant: 16),
|
||||
])
|
||||
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
|
||||
self.changeSelectionPanRecognizer = panRecognizer
|
||||
panRecognizer.delegate = self
|
||||
optionsStack.addGestureRecognizer(panRecognizer)
|
||||
optionsStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(optionTapped)))
|
||||
optionsStack.addInteraction(UIPointerInteraction(delegate: self))
|
||||
|
||||
self.panGestureRecognizer.delegate = self
|
||||
|
||||
selectedIndicatorView.isHidden = true
|
||||
selectedIndicatorView.backgroundColor = .tintColor
|
||||
selectedIndicatorView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(selectedIndicatorView)
|
||||
NSLayoutConstraint.activate([
|
||||
selectedIndicatorView.heightAnchor.constraint(equalToConstant: 4),
|
||||
selectedIndicatorView.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func createOptionViews() {
|
||||
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
for (index, option) in options.enumerated() {
|
||||
let label = UILabel()
|
||||
label.text = option.name
|
||||
label.font = .preferredFont(forTextStyle: .headline)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.textColor = .secondaryLabel
|
||||
label.textAlignment = .center
|
||||
label.accessibilityTraits = .button
|
||||
label.accessibilityLabel = "\(option.name), \(index + 1) of \(options.count)"
|
||||
optionsStack.addArrangedSubview(label)
|
||||
}
|
||||
}
|
||||
|
||||
func setSelectedOption(_ value: Value, animated: Bool) {
|
||||
guard selectedOption != value,
|
||||
options.contains(where: { $0.value == value }) else {
|
||||
return
|
||||
}
|
||||
|
||||
if selectedOption != nil {
|
||||
selectionChangedFeedbackGenerator.selectionChanged()
|
||||
}
|
||||
|
||||
selectedOption = value
|
||||
didSelectOption?(value)
|
||||
updateSelectedIndicatorView()
|
||||
|
||||
if animated && !selectedIndicatorView.isHidden {
|
||||
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectedIndicatorView() {
|
||||
guard let selectedOption,
|
||||
let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }) else {
|
||||
selectedIndicatorView.isHidden = true
|
||||
return
|
||||
}
|
||||
let selectedOptionView = optionsStack.arrangedSubviews[selectedIndex]
|
||||
selectedIndicatorView.isHidden = false
|
||||
NSLayoutConstraint.deactivate(selectedIndicatorViewAlignmentConstraints)
|
||||
selectedIndicatorViewAlignmentConstraints = [
|
||||
selectedIndicatorView.leadingAnchor.constraint(equalTo: selectedOptionView.leadingAnchor),
|
||||
selectedIndicatorView.trailingAnchor.constraint(equalTo: selectedOptionView.trailingAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(selectedIndicatorViewAlignmentConstraints)
|
||||
|
||||
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() {
|
||||
let label = optionView as! UILabel
|
||||
label.textColor = index == selectedIndex ? .label : .secondaryLabel
|
||||
label.accessibilityTraits = index == selectedIndex ? [.button, .selected] : .button
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
let beganOnSelectedOption: Bool
|
||||
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
|
||||
optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
|
||||
beganOnSelectedOption = true
|
||||
} else {
|
||||
beganOnSelectedOption = false
|
||||
}
|
||||
|
||||
// only begin changing selection if the gesutre started on the currently selected item
|
||||
// otherwise, let the scroll view handle things
|
||||
if gestureRecognizer == self.changeSelectionPanRecognizer {
|
||||
return beganOnSelectedOption
|
||||
} else {
|
||||
return !beganOnSelectedOption
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||
let horizontalLocationInStack = CGPoint(x: recognizer.location(in: optionsStack).x, y: 0)
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
selectedOptionAtStartOfPan = selectedOption
|
||||
selectionChangedFeedbackGenerator.prepare()
|
||||
|
||||
case .changed:
|
||||
if updateSelectionFor(location: horizontalLocationInStack) {
|
||||
selectionChangedFeedbackGenerator.prepare()
|
||||
}
|
||||
|
||||
case .ended:
|
||||
if let selectedOptionAtStartOfPan {
|
||||
self.selectedOptionAtStartOfPan = nil
|
||||
if let selectedOption,
|
||||
selectedOptionAtStartOfPan != selectedOption {
|
||||
didSelectOption?(selectedOption)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func optionTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
let location = recognizer.location(in: optionsStack)
|
||||
if updateSelectionFor(location: location) {
|
||||
didSelectOption?(selectedOption!)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionFor(location: CGPoint) -> Bool {
|
||||
for (index, optionView) in optionsStack.arrangedSubviews.enumerated() where optionView.frame.contains(location) {
|
||||
if selectedOption != options[index].value {
|
||||
selectedOption = options[index].value
|
||||
let animator = UIViewPropertyAnimator(duration: 0.4, dampingRatio: 0.8) {
|
||||
self.updateSelectedIndicatorView()
|
||||
self.scrollRectToVisible(optionView.frame.insetBy(dx: -16, dy: 0), animated: false)
|
||||
self.layoutIfNeeded()
|
||||
}
|
||||
animator.startAnimation()
|
||||
selectionChangedFeedbackGenerator.selectionChanged()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
|
||||
func distanceToAnyEdge(_ view: UIView) -> CGFloat {
|
||||
min(abs(view.frame.minX - request.location.x), abs(view.frame.maxX - request.location.x))
|
||||
}
|
||||
let (view, index, _) = optionsStack.arrangedSubviews.enumerated().map { ($0.1, $0.0, distanceToAnyEdge($0.1)) }.min(by: { $0.2 < $1.2 })!
|
||||
return UIPointerRegion(rect: view.frame.insetBy(dx: -8, dy: 0), identifier: index)
|
||||
}
|
||||
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||
let index = region.identifier as! Int
|
||||
let optionView = optionsStack.arrangedSubviews[index]
|
||||
return UIPointerStyle(effect: .hover(UITargetedPreview(view: optionView)))
|
||||
}
|
||||
|
||||
struct Option: Hashable {
|
||||
let value: Value
|
||||
let name: String
|
||||
}
|
||||
}
|
|
@ -231,16 +231,17 @@ class BaseStatusTableViewCell: UITableViewCell {
|
|||
|
||||
func updateUIForPreferences(account: AccountMO, status: StatusMO) {
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||
if Preferences.shared.blurAllMedia {
|
||||
attachmentsView.contentHidden = true
|
||||
} else if status.sensitive {
|
||||
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
|
||||
switch Preferences.shared.attachmentBlurMode {
|
||||
case .never:
|
||||
attachmentsView.contentHidden = false
|
||||
} else {
|
||||
case .always:
|
||||
attachmentsView.contentHidden = true
|
||||
default:
|
||||
if status.sensitive {
|
||||
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||
} else {
|
||||
attachmentsView.contentHidden = false
|
||||
}
|
||||
} else {
|
||||
attachmentsView.contentHidden = false
|
||||
}
|
||||
|
||||
updateStatusIconsForPreferences(status)
|
||||
|
|
|
@ -148,16 +148,17 @@ extension StatusCollectionViewCell {
|
|||
|
||||
func baseUpdateUIForPreferences(status: StatusMO) {
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
|
||||
if Preferences.shared.blurAllMedia {
|
||||
contentContainer.attachmentsView.contentHidden = true
|
||||
} else if status.sensitive {
|
||||
if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
|
||||
switch Preferences.shared.attachmentBlurMode {
|
||||
case .never:
|
||||
contentContainer.attachmentsView.contentHidden = false
|
||||
} else {
|
||||
case .always:
|
||||
contentContainer.attachmentsView.contentHidden = true
|
||||
default:
|
||||
if status.sensitive {
|
||||
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
|
||||
} else {
|
||||
contentContainer.attachmentsView.contentHidden = false
|
||||
}
|
||||
} else {
|
||||
contentContainer.attachmentsView.contentHidden = false
|
||||
}
|
||||
|
||||
let reblogButtonImage: UIImage
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -369,7 +369,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return nil
|
||||
}
|
||||
var str = AttributedString("\(status.account.displayOrUserName), ")
|
||||
var str: AttributedString = ""
|
||||
if let rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
str += AttributedString("Reblogged by \(reblogger.displayNameWithoutCustomEmoji): ")
|
||||
}
|
||||
str += AttributedString(status.account.displayNameWithoutCustomEmoji)
|
||||
str += ", "
|
||||
if statusState.collapsed ?? false {
|
||||
if !status.spoilerText.isEmpty {
|
||||
str += AttributedString(status.spoilerText)
|
||||
|
@ -378,15 +384,37 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
str += "collapsed"
|
||||
} else {
|
||||
str += AttributedString(contentTextView.attributedText)
|
||||
}
|
||||
|
||||
if status.attachments.count > 0 {
|
||||
// TODO: localize me
|
||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
||||
let includeDescriptions: Bool
|
||||
switch Preferences.shared.attachmentBlurMode {
|
||||
case .useStatusSetting:
|
||||
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || status.spoilerText.isEmpty
|
||||
case .always:
|
||||
includeDescriptions = true
|
||||
case .never:
|
||||
includeDescriptions = false
|
||||
}
|
||||
if includeDescriptions {
|
||||
if status.attachments.count == 1 {
|
||||
let attachment = status.attachments[0]
|
||||
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||
str += AttributedString(", attachment: \(desc)")
|
||||
} else {
|
||||
for (index, attachment) in status.attachments.enumerated() {
|
||||
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||
str += AttributedString(", attachment \(index + 1): \(desc)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count == 1 ? "" : "s")")
|
||||
}
|
||||
}
|
||||
if status.poll != nil {
|
||||
str += ", poll"
|
||||
}
|
||||
}
|
||||
|
||||
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||
if status.visibility < .unlisted {
|
||||
str += AttributedString(", \(status.visibility.displayName)")
|
||||
|
@ -394,10 +422,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
if status.localOnly {
|
||||
str += ", local"
|
||||
}
|
||||
if let rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
|
||||
}
|
||||
return NSAttributedString(str)
|
||||
}
|
||||
set {}
|
||||
|
|
|
@ -254,7 +254,13 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return nil
|
||||
}
|
||||
var str = AttributedString("\(status.account.displayOrUserName), ")
|
||||
var str: AttributedString = ""
|
||||
if let rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
str += AttributedString("Reblogged by \(reblogger.displayNameWithoutCustomEmoji): ")
|
||||
}
|
||||
str += AttributedString(status.account.displayNameWithoutCustomEmoji)
|
||||
str += ", "
|
||||
if statusState.collapsed ?? false {
|
||||
if !status.spoilerText.isEmpty {
|
||||
str += AttributedString(status.spoilerText)
|
||||
|
@ -263,15 +269,37 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
str += "collapsed"
|
||||
} else {
|
||||
str += AttributedString(contentTextView.attributedText)
|
||||
}
|
||||
|
||||
if status.attachments.count > 0 {
|
||||
// TODO: localize me
|
||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count > 1 ? "s" : "")")
|
||||
let includeDescriptions: Bool
|
||||
switch Preferences.shared.attachmentBlurMode {
|
||||
case .useStatusSetting:
|
||||
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || status.spoilerText.isEmpty
|
||||
case .always:
|
||||
includeDescriptions = true
|
||||
case .never:
|
||||
includeDescriptions = false
|
||||
}
|
||||
if includeDescriptions {
|
||||
if status.attachments.count == 1 {
|
||||
let attachment = status.attachments[0]
|
||||
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||
str += AttributedString(", attachment: \(desc)")
|
||||
} else {
|
||||
for (index, attachment) in status.attachments.enumerated() {
|
||||
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
|
||||
str += AttributedString(", attachment \(index + 1): \(desc)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
str += AttributedString(", \(status.attachments.count) attachment\(status.attachments.count == 1 ? "" : "s")")
|
||||
}
|
||||
}
|
||||
if status.poll != nil {
|
||||
str += ", poll"
|
||||
}
|
||||
}
|
||||
|
||||
str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
|
||||
if status.visibility < .unlisted {
|
||||
str += AttributedString(", \(status.visibility.displayName)")
|
||||
|
@ -279,10 +307,6 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
|
|||
if status.localOnly {
|
||||
str += ", local"
|
||||
}
|
||||
if let rebloggerID,
|
||||
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
|
||||
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
|
||||
}
|
||||
return NSAttributedString(str)
|
||||
}
|
||||
set {}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue