Compare commits

..

No commits in common. "5ee140cdab142294dc3e851474874676ca92a1fa" and "13d649bacec6258c44416d04bf2d03d234b56f02" have entirely different histories.

101 changed files with 314 additions and 960 deletions

View File

@ -1,33 +1,5 @@
# Changelog # 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) ## 2022.1 (51)
Features/Improvements: Features/Improvements:
- Clarify text for conversation main status favorite/reblog count preference - Clarify text for conversation main status favorite/reblog count preference

View File

@ -304,8 +304,6 @@
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; }; D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */; };
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; }; D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; }; D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */; };
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D706A62948D4D0000827ED /* TimlineState.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; }; D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; }; D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; }; D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
@ -561,7 +559,7 @@
D66A77BA233838DC0058F1EC /* UIFont+Traits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+Traits.swift"; sourceTree = "<group>"; }; 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>"; }; 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>"; }; D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuPicker.swift; sourceTree = "<group>"; };
D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Pachyderm; path = Packages/Pachyderm; sourceTree = "<group>"; }; D674A50727F910F300BA03AC /* Pachyderm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Pachyderm; sourceTree = "<group>"; };
D677284724ECBCB100C732D3 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; 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>"; }; 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>"; }; D677284B24ECBE9100C732D3 /* ComposeAvatarImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAvatarImageView.swift; sourceTree = "<group>"; };
@ -698,8 +696,6 @@
D6D4DDEB212518A200E1C4BB /* TuskerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TuskerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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>"; }; D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; };
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingSegmentedControl.swift; sourceTree = "<group>"; };
D6D706A62948D4D0000827ED /* TimlineState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimlineState.swift; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; }; D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; }; D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; }; D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
@ -935,7 +931,6 @@
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */, D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
D61F759A29384F9C00C0B37F /* FilterMO.swift */, D61F759A29384F9C00C0B37F /* FilterMO.swift */,
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */, D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
D6D706A62948D4D0000827ED /* TimlineState.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
); );
@ -1369,7 +1364,6 @@
D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */, D673ACCD2919E74200D6F8B0 /* MenuPicker.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */, D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */, D627943A23A55BA600D38C68 /* NavigableTableViewCell.swift */,
D6D7069F29466649000827ED /* ScrollingSegmentedControl.swift */,
D627943123A5466600D38C68 /* SelectableTableViewCell.swift */, D627943123A5466600D38C68 /* SelectableTableViewCell.swift */,
D620483723D38190008A63EF /* StatusContentTextView.swift */, D620483723D38190008A63EF /* StatusContentTextView.swift */,
04ED00B021481ED800567C53 /* SteppedProgressView.swift */, 04ED00B021481ED800567C53 /* SteppedProgressView.swift */,
@ -1415,6 +1409,7 @@
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */,
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */, D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */,
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */, D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */,
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
D61F759129365C6C00C0B37F /* CollectionViewController.swift */, D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
@ -1488,7 +1483,6 @@
D6F1F84E2193B9BE00F5FE67 /* Caching */, D6F1F84E2193B9BE00F5FE67 /* Caching */,
D6370B9924421FE00092A7FF /* CoreData */, D6370B9924421FE00092A7FF /* CoreData */,
D667E5F62135C2ED0057A976 /* Extensions */, D667E5F62135C2ED0057A976 /* Extensions */,
D6D706A12947D954000827ED /* Gestures */,
D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */, D6D4DDD8212518A200E1C4BB /* LaunchScreen.storyboard */,
D6E57FA525C26FAB00341037 /* Localizable.stringsdict */, D6E57FA525C26FAB00341037 /* Localizable.stringsdict */,
D61959D2241E846D00A37B8E /* Models */, D61959D2241E846D00A37B8E /* Models */,
@ -1529,14 +1523,6 @@
path = TuskerUITests; path = TuskerUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6D706A12947D954000827ED /* Gestures */ = {
isa = PBXGroup;
children = (
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
);
path = Gestures;
sourceTree = "<group>";
};
D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = { D6DEA0DB268400AF00FE896A /* Confirm Load More Cell */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2049,7 +2035,6 @@
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */, D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */, D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */, D6333B792139AEFD00CE884A /* Date+TimeAgo.swift in Sources */,
D6D706A029466649000827ED /* ScrollingSegmentedControl.swift in Sources */,
D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */, D653F411267D1E32004E32B1 /* DiffableTimelineLikeTableViewController.swift in Sources */,
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */, D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */, D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
@ -2074,7 +2059,6 @@
D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */, D6114E1327F89B440080E273 /* TrendingLinkTableViewCell.swift in Sources */,
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */, D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */, D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D6D706A72948D4D0000827ED /* TimlineState.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */, D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
@ -2307,7 +2291,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2375,7 +2359,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2525,7 +2509,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2554,7 +2538,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements; CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2664,7 +2648,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2691,7 +2675,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements; CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 52; CURRENT_PROJECT_VERSION = 51;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -257,8 +257,8 @@ private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
] ]
if let nodeInfo { if let nodeInfo {
crumb.data!["nodeInfo"] = [ crumb.data!["nodeInfo"] = [
"software": nodeInfo.software.name, "version": nodeInfo.version,
"version": nodeInfo.software.version, "software": nodeInfo.software,
] ]
} }
SentrySDK.addBreadcrumb(crumb: crumb) SentrySDK.addBreadcrumb(crumb: crumb)

View File

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

View File

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

View File

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

View File

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

View File

@ -20,30 +20,18 @@ extension AccountMO {
} }
var displayNameWithoutCustomEmoji: String { var displayNameWithoutCustomEmoji: String {
let stripped = stripCustomEmoji(from: displayName) if displayName.isEmpty {
if stripped.isEmpty {
return username return username
} else { } else {
return stripped return stripCustomEmoji(from: displayName)
} }
} }
} private static let customEmojiRegex = try! NSRegularExpression(pattern: ":[a-zA-Z0-9_]+:", options: [])
extension Account { private func stripCustomEmoji(from string: String) -> String {
var displayNameWithoutCustomEmoji: String { let range = NSRange(location: 0, length: string.utf16.count)
let stripped = stripCustomEmoji(from: displayName) return AccountMO.customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
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 customEmojiRegex.stringByReplacingMatches(in: string, options: [], range: range, withTemplate: "")
} }

View File

@ -106,9 +106,9 @@ struct HTMLConverter {
let index = (try? node.elementSiblingIndex()) ?? 0 let index = (try? node.elementSiblingIndex()) ?? 0
// we use the monospace digit font so that the periods of all the list items line up // we use the monospace digit font so that the periods of all the list items line up
// TODO: this probably breaks with dynamic type // TODO: this probably breaks with dynamic type
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular), .foregroundColor: color]) bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular)])
} else if parentTag == "ul" { } else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font, .foregroundColor: color]) bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font])
} else { } else {
bullet = NSAttributedString() bullet = NSAttributedString()
} }

View File

@ -43,8 +43,6 @@ class Preferences: Codable, ObservableObject {
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon) self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon) self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false 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.defaultPostVisibility = try container.decode(Status.Visibility.self, forKey: .defaultPostVisibility)
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
@ -54,11 +52,7 @@ class Preferences: Codable, ObservableObject {
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) { self.blurAllMedia = try container.decode(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.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
@ -92,8 +86,6 @@ class Preferences: Codable, ObservableObject {
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon) try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon) try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline) 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(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
@ -103,7 +95,7 @@ class Preferences: Codable, ObservableObject {
try container.encode(mentionReblogger, forKey: .mentionReblogger) try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard) try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode) try container.encode(blurAllMedia, forKey: .blurAllMedia)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning) try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs) try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
@ -148,12 +140,10 @@ class Preferences: Codable, ObservableObject {
@Published var useTwitterKeyboard = false @Published var useTwitterKeyboard = false
// MARK: Media // MARK: Media
@Published var attachmentBlurMode = AttachmentBlurMode.useStatusSetting { @Published var blurAllMedia = false {
didSet { didSet {
if attachmentBlurMode == .always { if blurAllMedia {
blurMediaBehindContentWarning = true blurMediaBehindContentWarning = true
} else if attachmentBlurMode == .never {
blurMediaBehindContentWarning = false
} }
} }
} }
@ -192,8 +182,6 @@ class Preferences: Codable, ObservableObject {
case showIsStatusReplyIcon case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline case hideActionsInTimeline
case leadingStatusSwipeActions
case trailingStatusSwipeActions
case defaultPostVisibility case defaultPostVisibility
case defaultReplyVisibility case defaultReplyVisibility
@ -203,8 +191,7 @@ class Preferences: Codable, ObservableObject {
case mentionReblogger case mentionReblogger
case useTwitterKeyboard case useTwitterKeyboard
case blurAllMedia // only used for migration case blurAllMedia
case attachmentBlurMode
case blurMediaBehindContentWarning case blurMediaBehindContentWarning
case automaticallyPlayGifs case automaticallyPlayGifs
@ -267,23 +254,4 @@ 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 {} extension UIUserInterfaceStyle: Codable {}

View File

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

View File

@ -137,11 +137,6 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
} }
} }
override func accessibilityPerformEscape() -> Bool {
dismiss(animated: true)
return true
}
// MARK: - Page View Controller Data Source // MARK: - Page View Controller Data Source
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {

View File

@ -16,7 +16,6 @@ protocol ComposeUIStateDelegate: AnyObject {
func presentAssetPickerSheet() func presentAssetPickerSheet()
func presentComposeDrawing() func presentComposeDrawing()
func selectDraft(_ draft: Draft) func selectDraft(_ draft: Draft)
func paste(itemProviders: [NSItemProvider])
} }
class ComposeUIState: ObservableObject { class ComposeUIState: ObservableObject {

View File

@ -72,24 +72,8 @@ struct ComposeView: View {
return attachmentIds.contains { uiState.attachmentsMissingDescriptions.contains($0) } 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 { private var postButtonEnabled: Bool {
draft.hasContent draft.hasContent && charactersRemaining >= 0 && !isPosting && !requiresAttachmentDescriptions && (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
&& charactersRemaining >= 0
&& !isPosting
&& !requiresAttachmentDescriptions
&& validAttachmentCombination
&& (draft.poll == nil || draft.poll!.options.allSatisfy { !$0.text.isEmpty })
} }
var body: some View { var body: some View {

View File

@ -83,7 +83,7 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
@Environment(\.isEnabled) var isEnabled: Bool @Environment(\.isEnabled) var isEnabled: Bool
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let textView = WrappedTextView(uiState: uiState) let textView = WrappedTextView()
textView.delegate = context.coordinator textView.delegate = context.coordinator
textView.isEditable = true textView.isEditable = true
textView.backgroundColor = .clear textView.backgroundColor = .clear
@ -128,16 +128,6 @@ struct MainComposeWrappedTextView: UIViewRepresentable {
class WrappedTextView: UITextView { class WrappedTextView: UITextView {
private let formattingActions = [#selector(toggleBoldface(_:)), #selector(toggleItalics(_:))] 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 { override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if formattingActions.contains(action) { if formattingActions.contains(action) {
@ -164,14 +154,6 @@ 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 { class Coordinator: NSObject, UITextViewDelegate, ComposeInput, ComposeTextViewCaretScrolling {

View File

@ -354,7 +354,6 @@ class ConversationTableViewController: EnhancedTableViewController {
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { 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) let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv) show(conv)
} else { } else {
super.tableView(tableView, didSelectRowAt: indexPath) super.tableView(tableView, didSelectRowAt: indexPath)

View File

@ -84,7 +84,7 @@ class AddSavedHashtagViewController: UIViewController {
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonPressed)) navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed))
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -107,12 +107,15 @@ class AddSavedHashtagViewController: UIViewController {
} }
private func selectHashtag(_ hashtag: Hashtag) { private func selectHashtag(_ hashtag: Hashtag) {
show(HashtagTimelineViewController(for: hashtag, mastodonController: mastodonController), sender: nil) let context = mastodonController.persistentContainer.viewContext
_ = SavedHashtag(hashtag: hashtag, context: context)
try! context.save()
presentingViewController!.dismiss(animated: true)
} }
// MARK: - Interaction // MARK: - Interaction
@objc func doneButtonPressed() { @objc func cancelButtonPressed() {
dismiss(animated: true) dismiss(animated: true)
} }
@ -125,6 +128,11 @@ extension AddSavedHashtagViewController {
enum Item: Hashable { enum Item: Hashable {
case tag(Hashtag) case tag(Hashtag)
} }
// class DataSource: UITableViewDiffableDataSource<Section, Item> {
// override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
// return
// }
// }
} }
extension AddSavedHashtagViewController: UICollectionViewDelegate { extension AddSavedHashtagViewController: UICollectionViewDelegate {

View File

@ -24,7 +24,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
var searchControllerStatusOnAppearance: Bool? = nil var searchControllerStatusOnAppearance: Bool? = nil
private var cancellables = Set<AnyCancellable>() private var listsCancellable: AnyCancellable?
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -70,26 +70,12 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false 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(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
mastodonController.$lists listsCancellable = mastodonController.$lists
.sink { [unowned self] in self.reloadLists($0) } .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) { override func viewWillAppear(_ animated: Bool) {
@ -163,7 +149,9 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
addDiscoverSection(to: &snapshot) addDiscoverSection(to: &snapshot)
} }
snapshot.appendItems([.addList], toSection: .lists) snapshot.appendItems([.addList], toSection: .lists)
let hashtags = fetchHashtagItems(followed: mastodonController.followedHashtags) let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
snapshot.appendItems(hashtags, toSection: .savedHashtags) snapshot.appendItems(hashtags, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
let instances = fetchSavedInstances().map { let instances = fetchSavedInstances().map {
@ -205,16 +193,14 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
@MainActor @MainActor
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] { private func fetchSavedHashtags() -> [SavedHashtag] {
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [] let req = SavedHashtag.fetchRequest()
var items = saved.map { req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch {
return []
} }
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 @MainActor
@ -228,10 +214,12 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
} }
private func updateHashtagsSection(followed: [FollowedHashtag]) { @objc private func savedHashtagsChanged() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
let hashtags = fetchHashtagItems(followed: followed) let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
snapshot.appendItems(hashtags, toSection: .savedHashtags) snapshot.appendItems(hashtags, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
dataSource.apply(snapshot) dataSource.apply(snapshot)
@ -398,7 +386,7 @@ extension ExploreViewController {
case .lists: case .lists:
return NSLocalizedString("Lists", comment: "explore lists section title") return NSLocalizedString("Lists", comment: "explore lists section title")
case .savedHashtags: case .savedHashtags:
return NSLocalizedString("Hashtags", comment: "explore saved hashtags section title") return NSLocalizedString("Saved Hashtags", comment: "explore saved hashtags section title")
case .savedInstances: case .savedInstances:
return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title") return NSLocalizedString("Instance Timelines", comment: "explore instance timelines section title")
} }
@ -437,7 +425,7 @@ extension ExploreViewController {
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
return hashtag.name return hashtag.name
case .addSavedHashtag: case .addSavedHashtag:
return NSLocalizedString("Add Hashtag...", comment: "save hashtag nav item title") return NSLocalizedString("Save Hashtag...", comment: "save hashtag nav item title")
case let .savedInstance(url): case let .savedInstance(url):
return url.host! return url.host!
case .findInstance: case .findInstance:

View File

@ -123,23 +123,4 @@ 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 {}
}
} }

View File

@ -34,9 +34,8 @@ class ProfileDirectoryViewController: UIViewController {
title = NSLocalizedString("Profile Directory", comment: "profile directory title") title = NSLocalizedString("Profile Directory", comment: "profile directory title")
let filterItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), menu: nil) // todo: it would be nice if there were a better "filter" icon
filterItem.accessibilityLabel = "Filter" navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "scope"), menu: nil)
navigationItem.rightBarButtonItem = filterItem
updateFilterMenu() updateFilterMenu()
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) in

View File

@ -85,14 +85,14 @@ class TrendingStatusesViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
if !loaded { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
loaded = true snapshot.appendSections([.statuses])
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() snapshot.appendItems([.loadingIndicator])
snapshot.appendSections([.statuses]) dataSource.apply(snapshot, animatingDifferences: false)
snapshot.appendItems([.loadingIndicator])
dataSource.apply(snapshot, animatingDifferences: false)
Task { Task {
if !loaded {
loaded = true
await loadTrendingStatuses() await loadTrendingStatuses()
} }
} }

View File

@ -17,10 +17,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
@IBOutlet weak var scrollView: UIScrollView! @IBOutlet weak var scrollView: UIScrollView!
@IBOutlet weak var topControlsView: UIView! @IBOutlet weak var topControlsView: UIView!
@IBOutlet weak var descriptionTextView: UITextView! @IBOutlet weak var bottomControlsView: UIView!
@IBOutlet weak var descriptionLabel: UILabel!
private var shareContainer: UIView! private var shareContainer: UIView!
private var closeContainer: UIView!
private var shareImage: UIImageView! private var shareImage: UIImageView!
private var shareButtonTopConstraint: NSLayoutConstraint! private var shareButtonTopConstraint: NSLayoutConstraint!
private var shareButtonLeadingConstraint: NSLayoutConstraint! private var shareButtonLeadingConstraint: NSLayoutConstraint!
@ -46,8 +46,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
var shrinkGestureEnabled = true var shrinkGestureEnabled = true
private var isInitialAppearance = true
private var skipUpdatingControlsWhileZooming = false
private var prevZoomScale: CGFloat? private var prevZoomScale: CGFloat?
private var isGrayscale = false private var isGrayscale = false
private var contentViewSizeObservation: NSKeyValueObservation? private var contentViewSizeObservation: NSKeyValueObservation?
@ -100,14 +98,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
if let imageDescription = imageDescription, if let imageDescription = imageDescription,
!imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { !imageDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
descriptionTextView.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines) descriptionLabel.text = imageDescription.trimmingCharacters(in: .whitespacesAndNewlines)
descriptionTextView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
// i'm not sure why .automatic doesn't work for this
descriptionTextView.contentInsetAdjustmentBehavior = .always
let height = min(150, descriptionTextView.contentSize.height)
descriptionTextView.topAnchor.constraint(equalTo: descriptionTextView.safeAreaLayoutGuide.bottomAnchor, constant: -(height + 16)).isActive = true
} else { } else {
descriptionTextView.isHidden = true bottomControlsView.isHidden = true
} }
if shrinkGestureEnabled { if shrinkGestureEnabled {
@ -123,12 +116,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
view.addGestureRecognizer(doubleTap) view.addGestureRecognizer(doubleTap)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
accessibilityElements = [
topControlsView!,
contentView,
descriptionTextView!,
]
} }
private func setupContentView() { private func setupContentView() {
@ -148,9 +135,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
private func setupControls() { private func setupControls() {
shareContainer = UIView() shareContainer = UIView()
shareContainer.isAccessibilityElement = true
shareContainer.accessibilityTraits = .button
shareContainer.accessibilityLabel = "Share"
shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed))) shareContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(sharePressed)))
shareContainer.translatesAutoresizingMaskIntoConstraints = false shareContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(shareContainer) topControlsView.addSubview(shareContainer)
@ -177,10 +161,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
shareImage.heightAnchor.constraint(equalToConstant: 24), shareImage.heightAnchor.constraint(equalToConstant: 24),
]) ])
closeContainer = UIView() let closeContainer = UIView()
closeContainer.isAccessibilityElement = true
closeContainer.accessibilityTraits = .button
closeContainer.accessibilityLabel = "Close"
closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed))) closeContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(closeButtonPressed)))
closeContainer.translatesAutoresizingMaskIntoConstraints = false closeContainer.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeContainer) topControlsView.addSubview(closeContainer)
@ -217,11 +198,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
let heightScale = maxHeight / contentView.intrinsicContentSize.height let heightScale = maxHeight / contentView.intrinsicContentSize.height
let widthScale = view.bounds.width / contentView.intrinsicContentSize.width let widthScale = view.bounds.width / contentView.intrinsicContentSize.width
let minScale = min(widthScale, heightScale) let minScale = min(widthScale, heightScale)
skipUpdatingControlsWhileZooming = true
scrollView.minimumZoomScale = minScale scrollView.minimumZoomScale = minScale
scrollView.zoomScale = minScale scrollView.zoomScale = minScale
scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2 scrollView.maximumZoomScale = minScale >= 1 ? minScale + 2 : 2
skipUpdatingControlsWhileZooming = false
centerImage() centerImage()
@ -251,26 +230,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
} }
} }
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
// the controls view transforms take the safe area insets into account, so they need to be updated
updateControlsView()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// on the first appearance, the text view flashes its own scroll indicators automatically
// so we only need to do it on subsequent appearances
if isInitialAppearance {
isInitialAppearance = false
} else {
if animated && controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
}
}
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages isGrayscale = Preferences.shared.grayscaleImages
@ -285,20 +244,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
self.contentView.setControlsVisible(controlsVisible) self.contentView.setControlsVisible(controlsVisible)
self.updateControlsView() self.updateControlsView()
} }
if controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
} else { } else {
updateControlsView() updateControlsView()
} }
} }
func updateControlsView() { func updateControlsView() {
let topOffset = self.controlsVisible ? 0 : -(self.topControlsView.bounds.height + self.view.safeAreaInsets.top) let topOffset = self.controlsVisible ? 0 : -self.topControlsView.bounds.height
self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset) self.topControlsView.transform = CGAffineTransform(translationX: 0, y: topOffset)
if self.imageDescription != nil { if self.imageDescription != nil {
let bottomOffset = self.controlsVisible ? 0 : self.descriptionTextView.bounds.height + self.view.safeAreaInsets.bottom let bottomOffset = self.controlsVisible ? 0 : self.bottomControlsView.bounds.height + self.view.safeAreaInsets.bottom
self.descriptionTextView.transform = CGAffineTransform(translationX: 0, y: bottomOffset) self.bottomControlsView.transform = CGAffineTransform(translationX: 0, y: bottomOffset)
} }
} }
@ -308,12 +264,10 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
func scrollViewDidZoom(_ scrollView: UIScrollView) { func scrollViewDidZoom(_ scrollView: UIScrollView) {
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
if !skipUpdatingControlsWhileZooming { if scrollView.zoomScale <= scrollView.minimumZoomScale {
if scrollView.zoomScale <= scrollView.minimumZoomScale { setControlsVisible(true, animated: true)
setControlsVisible(true, animated: true) } else if scrollView.zoomScale > prevZoomScale {
} else if scrollView.zoomScale > prevZoomScale { setControlsVisible(false, animated: true)
setControlsVisible(false, animated: true)
}
} }
self.prevZoomScale = scrollView.zoomScale self.prevZoomScale = scrollView.zoomScale
} }

View File

@ -10,7 +10,8 @@
<objects> <objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target"> <placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="LargeImageViewController" customModule="Tusker" customModuleProvider="target">
<connections> <connections>
<outlet property="descriptionTextView" destination="JZk-BO-2Vh" id="cby-Hc-ezg"/> <outlet property="bottomControlsView" destination="rPa-Zu-T6g" id="Rgz-AQ-9nt"/>
<outlet property="descriptionLabel" destination="eo5-fc-RV8" id="vrW-RJ-y5k"/>
<outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/> <outlet property="scrollView" destination="Skj-xq-AgQ" id="TFb-zF-m1b"/>
<outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/> <outlet property="topControlsView" destination="kHo-B9-R7a" id="8sJ-xQ-7ix"/>
<outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/> <outlet property="view" destination="BJw-5C-9nT" id="1C2-VA-mNf"/>
@ -28,34 +29,41 @@
<view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a"> <view contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="kHo-B9-R7a">
<rect key="frame" x="0.0" y="0.0" width="375" height="36"/> <rect key="frame" x="0.0" y="0.0" width="375" height="36"/>
</view> </view>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" editable="NO" textAlignment="natural" adjustsFontForContentSizeCategory="YES" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="JZk-BO-2Vh"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="rPa-Zu-T6g">
<rect key="frame" x="0.0" y="517" width="375" height="150"/> <rect key="frame" x="0.0" y="622.5" width="375" height="44.5"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.5" colorSpace="custom" customColorSpace="displayP3"/> <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"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="150" placeholder="YES" id="YfV-kQ-0RT"/> <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"/>
</constraints> </constraints>
<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> </view>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView>
</subviews> </subviews>
<viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/> <viewLayoutGuide key="safeArea" id="w1g-VC-Ll9"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<gestureRecognizers/> <gestureRecognizers/>
<constraints> <constraints>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerY" secondItem="BJw-5C-9nT" secondAttribute="centerY" id="0Xb-ib-2hg"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="centerY" secondItem="BJw-5C-9nT" secondAttribute="centerY" id="0Xb-ib-2hg"/>
<constraint firstAttribute="bottom" secondItem="JZk-BO-2Vh" secondAttribute="bottom" id="7Z2-gW-sPj"/> <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 firstItem="kHo-B9-R7a" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="IvH-gU-Kie"/> <constraint firstItem="kHo-B9-R7a" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="IvH-gU-Kie"/>
<constraint firstAttribute="trailing" secondItem="JZk-BO-2Vh" secondAttribute="trailing" id="JgV-jy-qjS"/>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="centerX" secondItem="BJw-5C-9nT" secondAttribute="centerX" id="KMe-Zc-NZq"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="centerX" secondItem="BJw-5C-9nT" secondAttribute="centerX" id="KMe-Zc-NZq"/>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="width" secondItem="BJw-5C-9nT" secondAttribute="width" id="Onj-l9-fBu"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="width" secondItem="BJw-5C-9nT" secondAttribute="width" id="Onj-l9-fBu"/>
<constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="kHo-B9-R7a" secondAttribute="trailing" id="Uh0-ub-R9V"/> <constraint firstItem="w1g-VC-Ll9" firstAttribute="trailing" secondItem="kHo-B9-R7a" secondAttribute="trailing" id="Uh0-ub-R9V"/>
<constraint firstItem="rPa-Zu-T6g" firstAttribute="leading" secondItem="w1g-VC-Ll9" secondAttribute="leading" id="asz-Xj-FUC"/>
<constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/> <constraint firstItem="Skj-xq-AgQ" firstAttribute="height" secondItem="BJw-5C-9nT" secondAttribute="height" id="jvz-QW-n9c"/>
<constraint firstItem="JZk-BO-2Vh" firstAttribute="leading" secondItem="BJw-5C-9nT" secondAttribute="leading" id="kkj-O9-1rE"/>
<constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/> <constraint firstItem="kHo-B9-R7a" firstAttribute="top" secondItem="BJw-5C-9nT" secondAttribute="top" id="n1O-C3-yQR"/>
</constraints> </constraints>
<point key="canvasLocation" x="-164" y="475.41229385307349"/> <point key="canvasLocation" x="-164" y="476"/>
</view> </view>
</objects> </objects>
</document> </document>

View File

@ -80,7 +80,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
return return
} }
config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
self.contentConfiguration = config self.contentConfiguration = contentConfiguration
} }
} }

View File

@ -30,7 +30,7 @@ class MainSidebarViewController: UIViewController {
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var cancellables = Set<AnyCancellable>() private var listsCancellable: AnyCancellable?
var allItems: [Item] { var allItems: [Item] {
[ [
@ -101,19 +101,12 @@ class MainSidebarViewController: UIViewController {
select(item: .tab(.timelines), animated: false) 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(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
mastodonController.$lists listsCancellable = mastodonController.$lists
.sink { [unowned self] in self.reloadLists($0) } .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?() onViewDidLoad?()
} }
@ -183,7 +176,7 @@ class MainSidebarViewController: UIViewController {
applyDiscoverSectionSnapshot() applyDiscoverSectionSnapshot()
reloadLists(mastodonController.lists) reloadLists(mastodonController.lists)
updateHashtagsSection(followed: mastodonController.followedHashtags) reloadSavedHashtags()
reloadSavedInstances() reloadSavedInstances()
} }
@ -231,16 +224,14 @@ class MainSidebarViewController: UIViewController {
} }
@MainActor @MainActor
private func fetchHashtagItems(followed: [FollowedHashtag]) -> [Item] { private func fetchSavedHashtags() -> [SavedHashtag] {
let saved = (try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [] let req = SavedHashtag.fetchRequest()
var items = saved.map { req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url)) do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch {
return []
} }
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 @MainActor
@ -254,8 +245,10 @@ class MainSidebarViewController: UIViewController {
} }
} }
private func updateHashtagsSection(followed: [FollowedHashtag]) { @objc private func reloadSavedHashtags() {
let hashtags = fetchHashtagItems(followed: followed) let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
if let selectedItem, if let selectedItem,
case .savedHashtag(_) = selectedItem, case .savedHashtag(_) = selectedItem,
!hashtags.contains(selectedItem) { !hashtags.contains(selectedItem) {
@ -410,13 +403,13 @@ extension MainSidebarViewController {
case .addList: case .addList:
return "New List..." return "New List..."
case .savedHashtagsHeader: case .savedHashtagsHeader:
return "Hashtags" return "Saved Hashtags"
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
return hashtag.name return hashtag.name
case .addSavedHashtag: case .addSavedHashtag:
return "Add Hashtag..." return "Save Hashtag..."
case .savedInstancesHeader: case .savedInstancesHeader:
return "Instance Timelines" return "Saved Instances"
case let .savedInstance(url): case let .savedInstance(url):
return url.host! return url.host!
case .addSavedInstance: case .addSavedInstance:

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class NotificationsPageViewController: SegmentedPageViewController<NotificationsPageViewController.Page> { class NotificationsPageViewController: SegmentedPageViewController {
private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title") private let notificationsTitle = NSLocalizedString("Notifications", comment: "notifications tab title")
private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title") private let mentionsTitle = NSLocalizedString("Mentions", comment: "mentions tab title")
@ -30,9 +30,12 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
mentions.title = mentionsTitle mentions.title = mentionsTitle
mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly) mentions.userActivity = UserActivityManager.checkNotificationsActivity(mode: .mentionsOnly)
super.init(pages: [ super.init(titles: [
(.all, notificationsTitle, notifications), notificationsTitle,
(.mentions, mentionsTitle, mentions), mentionsTitle
], pageControllers: [
notifications,
mentions
]) ])
title = notificationsTitle title = notificationsTitle
@ -50,20 +53,15 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
} }
func selectMode(_ mode: NotificationsMode) { func selectMode(_ mode: NotificationsMode) {
let page: Page let index: Int
switch mode { switch mode {
case .allNotifications: case .allNotifications:
page = .all index = 0
case .mentionsOnly: case .mentionsOnly:
page = .mentions index = 1
} }
segmentedControl.setSelectedOption(page, animated: false) segmentedControl.selectedSegmentIndex = index
selectPage(page, animated: false) selectPage(at: index, animated: false)
}
enum Page {
case all
case mentions
} }
} }

View File

@ -21,18 +21,14 @@ struct MediaPrefsView: View {
var viewingSection: some View { var viewingSection: some View {
Section(header: Text("Viewing")) { Section(header: Text("Viewing")) {
Picker(selection: $preferences.attachmentBlurMode) { Toggle(isOn: $preferences.blurAllMedia) {
ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in Text("Blur All Media")
Text(mode.displayName).tag(mode)
}
} label: {
Text("Blur Media")
} }
Toggle(isOn: $preferences.blurMediaBehindContentWarning) { Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
Text("Blur Media Behind Content Warning") Text("Blur Media Behind Content Warning")
} }
.disabled(preferences.attachmentBlurMode != .useStatusSetting) .disabled(preferences.blurAllMedia)
Toggle(isOn: $preferences.automaticallyPlayGifs) { Toggle(isOn: $preferences.automaticallyPlayGifs) {
Text("Automatically Play GIFs") Text("Automatically Play GIFs")

View File

@ -142,7 +142,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
let view = ProfileHeaderView.create() let view = ProfileHeaderView.create()
view.delegate = self.profileHeaderDelegate view.delegate = self.profileHeaderDelegate
view.updateUI(for: id) view.updateUI(for: id)
view.pagesSegmentedControl.setSelectedOption(self.owner!.currentPage, animated: false) view.pagesSegmentedControl.selectedSegmentIndex = self.owner?.currentIndex ?? 0
cell.addHeader(view) cell.addHeader(view)
case .useExistingView(let view): case .useExistingView(let view):
cell.addHeader(view) cell.addHeader(view)

View File

@ -29,11 +29,7 @@ class ProfileViewController: UIViewController {
} }
private(set) var currentIndex: Int! private(set) var currentIndex: Int!
private let pages = [Page.posts, .postsAndReplies, .media]
private var pageControllers: [ProfileStatusesViewController]! private var pageControllers: [ProfileStatusesViewController]!
var currentPage: Page {
pages[currentIndex]
}
var currentViewController: ProfileStatusesViewController { var currentViewController: ProfileStatusesViewController {
pageControllers[currentIndex] pageControllers[currentIndex]
} }
@ -287,14 +283,6 @@ class ProfileViewController: UIViewController {
} }
} }
extension ProfileViewController {
enum Page: Hashable {
case posts
case postsAndReplies
case media
}
}
extension ProfileViewController { extension ProfileViewController {
enum State { enum State {
case idle case idle
@ -310,25 +298,24 @@ extension ProfileViewController: ToastableViewController {
} }
extension ProfileViewController: ProfileHeaderViewDelegate { extension ProfileViewController: ProfileHeaderViewDelegate {
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) { func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) {
guard case .idle = state else { guard case .idle = state else {
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
return return
} }
selectPage(at: pages.firstIndex(of: newPage)!, animated: true) selectPage(at: newIndex, animated: true)
} }
} }
extension ProfileViewController: TabbedPageViewController { extension ProfileViewController: TabbedPageViewController {
func selectNextPage() { func selectNextPage() {
guard currentIndex < pageControllers.count - 1 else { return } guard currentIndex < pageControllers.count - 1 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true) currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex + 1
selectPage(at: currentIndex + 1, animated: true) selectPage(at: currentIndex + 1, animated: true)
} }
func selectPrevPage() { func selectPrevPage() {
guard currentIndex > 0 else { return } guard currentIndex > 0 else { return }
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true) currentViewController.headerCell?.view?.pagesSegmentedControl.selectedSegmentIndex = currentIndex - 1
selectPage(at: currentIndex - 1, animated: true) selectPage(at: currentIndex - 1, animated: true)
} }
} }

View File

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

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import SwiftUI import SwiftUI
class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageViewController.Page> { class TimelinesPageViewController: SegmentedPageViewController {
private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title") private let homeTitle = NSLocalizedString("Home", comment: "home timeline tab title")
private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title") private let federatedTitle = NSLocalizedString("Federated", comment: "federated timeline tab title")
@ -22,20 +22,21 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
let home = TimelineViewController(for: .home, mastodonController: mastodonController) let home = TimelineViewController(for: .home, mastodonController: mastodonController)
home.title = homeTitle home.title = homeTitle
home.persistsState = true
let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController) let federated = TimelineViewController(for: .public(local: false), mastodonController: mastodonController)
federated.title = federatedTitle federated.title = federatedTitle
federated.persistsState = true
let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController) let local = TimelineViewController(for: .public(local: true), mastodonController: mastodonController)
local.title = localTitle local.title = localTitle
local.persistsState = true
super.init(pages: [ super.init(titles: [
(.home, "Home", home), homeTitle,
(.local, "Local", local), federatedTitle,
(.federated, "Federated", federated), localTitle
], pageControllers: [
home,
federated,
local
]) ])
title = homeTitle title = homeTitle
@ -74,28 +75,24 @@ class TimelinesPageViewController: SegmentedPageViewController<TimelinesPageView
guard let timeline = UserActivityManager.getTimeline(from: activity) else { guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return return
} }
let page: Page let index: Int
switch timeline { switch timeline {
case .home: case .home:
page = .home index = 0
case .public(local: false): case .public(local: false):
page = .federated index = 1
case .public(local: true): case .public(local: true):
page = .local index = 2
default: default:
return return
} }
selectPage(page, animated: false) selectPage(at: index, animated: false)
let timelineVC = pageControllers[index] as! TimelineViewController
timelineVC.restoreActivity(activity)
} }
@objc private func filtersPressed() { @objc private func filtersPressed() {
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true) present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
} }
enum Page: Hashable {
case home
case local
case federated
}
} }

View File

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

View File

@ -8,45 +8,33 @@
import UIKit import UIKit
class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageViewControllerDelegate, TabbedPageViewController { class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDelegate {
let pages: [Page] let titles: [String]
let pageControllers: [UIViewController] let pageControllers: [UIViewController]
private var initialPage: Page private var initialIndex = 0
private var currentPage: Page private(set) var currentIndex = 0
var currentIndex: Int {
pages.firstIndex(of: currentPage)!
}
let segmentedControl = ScrollingSegmentedControl<Page>() var segmentedControl: UISegmentedControl!
init(pages: [(Page, String, UIViewController)]) { init(titles: [String], pageControllers: [UIViewController]) {
precondition(!pages.isEmpty) precondition(!pageControllers.isEmpty)
self.pages = pages.map(\.0) self.titles = titles
self.pageControllers = pages.map(\.2) self.pageControllers = pageControllers
initialPage = self.pages.first!
currentPage = self.pages.first!
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) 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 // this needs to happen in init because EnhancedNavigationViewController expects to be able to look at the titleView
// before the view has necessarily loaded // before the view has necessarily loaded
segmentedControl.options = pages.map { segmentedControl = UISegmentedControl(items: titles)
.init(value: $0.0, name: $0.1) segmentedControl.addTarget(self, action: #selector(segmentedControlChanged), for: .valueChanged)
}
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, // 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 // so make it clear that to switch tabs the user needs to enter the group
segmentedControl.accessibilityHint = "Enter group to select timeline" segmentedControl.accessibilityHint = "Enter group to select timeline"
segmentedControl.setSelectedOption(segmentedControl.options.first!.value, animated: false)
navigationItem.titleView = segmentedControl navigationItem.titleView = segmentedControl
} }
@ -59,7 +47,7 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
view.backgroundColor = .systemBackground view.backgroundColor = .systemBackground
selectPage(initialPage, animated: false) selectPage(at: initialIndex, animated: false)
addKeyCommand(MenuController.prevSubTabCommand) addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand) addKeyCommand(MenuController.nextSubTabCommand)
@ -72,36 +60,28 @@ class SegmentedPageViewController<Page: Hashable>: UIPageViewController, UIPageV
} }
} }
func selectPage(_ page: Page, animated: Bool) { func selectPage(at index: Int, animated: Bool) {
guard pages.contains(page) else {
fatalError("invalid page \(page) that is not in SegmentedPageViewController.pages")
}
guard isViewLoaded else { guard isViewLoaded else {
initialPage = page initialIndex = index
return return
} }
let prevIndex = currentIndex let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
currentPage = page setViewControllers([pageControllers[index]], direction: direction, animated: animated)
let index = pages.firstIndex(of: page)! navigationItem.title = pageControllers[index].title
let newController = pageControllers[index] currentIndex = index
segmentedControl.selectedSegmentIndex = 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)
} }
// MARK: TabbedPageViewController @objc func segmentedControlChanged() {
selectPage(at: segmentedControl.selectedSegmentIndex, animated: true)
func selectNextPage() { UIImpactFeedbackGenerator(style: .light).impactOccurred()
guard currentIndex < pageControllers.count - 1 else { return }
selectPage(pages[currentIndex + 1], animated: true)
} }
func selectPrevPage() { // MARK: - Page View Controller Delegate
guard currentIndex > 0 else { return } func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
selectPage(pages[currentIndex - 1], animated: true) currentIndex = pageControllers.firstIndex(of: viewControllers!.first!)!
segmentedControl.selectedSegmentIndex = currentIndex
navigationItem.title = viewControllers!.first!.title
} }
} }
@ -114,6 +94,18 @@ 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 { extension SegmentedPageViewController: BackgroundableViewController {
func sceneDidEnterBackground() { func sceneDidEnterBackground() {
if let current = pageControllers[currentIndex] as? BackgroundableViewController { if let current = pageControllers[currentIndex] as? BackgroundableViewController {

View File

@ -245,7 +245,7 @@ extension SplitNavigationController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
let vcs = viewControllers let vcs = viewControllers
if !canShowSecondaryNav || vcs.count < 2 { if !canShowSecondaryNav || vcs.count < 2 {
return (vcs.last! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue return (vcs.first! as? StatusBarTappableViewController)?.handleStatusBarTapped(xPosition: xPosition) ?? .continue
} else { } else {
let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view) let positionInRoot = rootNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)
let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view) let positionInSecondary = secondaryNav.view.convert(CGPoint(x: xPosition, y: 0), from: view)

View File

@ -217,19 +217,20 @@ class UserActivityManager {
switch timeline { switch timeline {
case .home, .public(true), .public(false): case .home, .public(true), .public(false):
navigationController.popToRootViewController(animated: false) navigationController.popToRootViewController(animated: false)
let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController let rootController = navigationController.viewControllers.first! as! SegmentedPageViewController
let page: TimelinesPageViewController.Page let index: Int
switch timeline { switch timeline {
case .home: case .home:
page = .home index = 0
case .public(local: false): case .public(false):
page = .federated index = 1
case .public(local: true): case .public(true):
page = .local index = 2
default: default:
fatalError() fatalError()
} }
rootController.selectPage(page, animated: false) rootController.segmentedControl.selectedSegmentIndex = index
rootController.selectPage(at: index, animated: false)
default: default:
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
navigationController.pushViewController(timeline, animated: false) navigationController.pushViewController(timeline, animated: false)

View File

@ -94,7 +94,7 @@ class AccountCollectionViewCell: UICollectionViewListCell {
let account = mastodonController.persistentContainer.account(for: accountID) else { let account = mastodonController.persistentContainer.account(for: accountID) else {
return nil return nil
} }
var str = AttributedString(account.displayNameWithoutCustomEmoji) var str = AttributedString(account.displayOrUserName)
str += ", @" str += ", @"
str += AttributedString(account.acct) str += AttributedString(account.acct)
return NSAttributedString(str) return NSAttributedString(str)

View File

@ -173,17 +173,15 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// MARK: - Navigation // 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) let text = (self.text as NSString).substring(with: range)
if let mention = getMention(for: url, text: text) { if let mention = getMention(for: url, text: text) {
return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!) return ProfileViewController(accountID: mention.id, mastodonController: mastodonController!)
} else if let tag = getHashtag(for: url, text: text) { } else if let tag = getHashtag(for: url, text: text) {
return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!) return HashtagTimelineViewController(for: tag, mastodonController: mastodonController!)
} else if url.scheme == "https" || url.scheme == "http" {
return SFSafariViewController(url: url)
} else { } else {
return nil return SFSafariViewController(url: url)
} }
} }

View File

@ -11,7 +11,7 @@ import Pachyderm
import Combine import Combine
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider { protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page) func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
} }
class ProfileHeaderView: UIView { class ProfileHeaderView: UIView {
@ -35,11 +35,10 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var lockImageView: UIImageView! @IBOutlet weak var lockImageView: UIImageView!
@IBOutlet weak var vStack: UIStackView!
@IBOutlet weak var relationshipLabel: UILabel! @IBOutlet weak var relationshipLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView! @IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var fieldsView: ProfileFieldsView!
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>! @IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
var accountID: String! var accountID: String!
@ -84,22 +83,6 @@ class ProfileHeaderView: UIView {
noteTextView.defaultFont = .preferredFont(forTextStyle: .body) noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.adjustsFontForContentSizeCategory = true 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, // 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 // so make it clear that to switch tabs the user needs to enter the group
pagesSegmentedControl.accessibilityHint = "Enter group to select scope" pagesSegmentedControl.accessibilityHint = "Enter group to select scope"
@ -281,6 +264,11 @@ class ProfileHeaderView: UIView {
delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView) 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 { extension ProfileHeaderView: UIPointerInteractionDelegate {

View File

@ -69,22 +69,42 @@
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </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"> <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="460"/> <rect key="frame" x="0.0" y="0.0" width="382" height="259.5"/>
<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> <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"/> <color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<textInputTraits key="textInputTraits" autocapitalizationType="sentences"/> <textInputTraits key="textInputTraits" autocapitalizationType="sentences"/>
</textView> </textView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="vKC-m1-Sbs" customClass="ProfileFieldsView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="468" width="398" height="128"/> <rect key="frame" x="0.0" y="267.5" width="398" height="128"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/> <constraint firstAttribute="height" constant="128" placeholder="YES" id="xbR-M6-H0I"/>
</constraints> </constraints>
</view> </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> </subviews>
<constraints> <constraints>
<constraint firstItem="vKC-m1-Sbs" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" id="0dI-ax-7eI"/> <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"/> <constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
</constraints> </constraints>
</stackView> </stackView>
@ -104,13 +124,6 @@
</imageView> </imageView>
</subviews> </subviews>
</stackView> </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> </subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
@ -120,11 +133,9 @@
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/> <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="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="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="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="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 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="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="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"/> <constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
@ -132,7 +143,6 @@
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/> <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="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="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 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 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"/> <constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
@ -147,9 +157,9 @@
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/> <outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/> <outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/> <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="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/> <outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
<outlet property="vStack" destination="u4P-3i-gEq" id="EUC-d2-cQC"/>
</connections> </connections>
<point key="canvasLocation" x="-590" y="117"/> <point key="canvasLocation" x="-590" y="117"/>
</view> </view>

View File

@ -1,228 +0,0 @@
//
// 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
}
}

View File

@ -231,17 +231,16 @@ class BaseStatusTableViewCell: UITableViewCell {
func updateUIForPreferences(account: AccountMO, status: StatusMO) { func updateUIForPreferences(account: AccountMO, status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
switch Preferences.shared.attachmentBlurMode { if Preferences.shared.blurAllMedia {
case .never:
attachmentsView.contentHidden = false
case .always:
attachmentsView.contentHidden = true attachmentsView.contentHidden = true
default: } else if status.sensitive {
if status.sensitive { if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
attachmentsView.contentHidden = false attachmentsView.contentHidden = false
} else {
attachmentsView.contentHidden = true
} }
} else {
attachmentsView.contentHidden = false
} }
updateStatusIconsForPreferences(status) updateStatusIconsForPreferences(status)

View File

@ -148,17 +148,16 @@ extension StatusCollectionViewCell {
func baseUpdateUIForPreferences(status: StatusMO) { func baseUpdateUIForPreferences(status: StatusMO) {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * Self.avatarImageViewSize
switch Preferences.shared.attachmentBlurMode { if Preferences.shared.blurAllMedia {
case .never:
contentContainer.attachmentsView.contentHidden = false
case .always:
contentContainer.attachmentsView.contentHidden = true contentContainer.attachmentsView.contentHidden = true
default: } else if status.sensitive {
if status.sensitive { if !Preferences.shared.blurMediaBehindContentWarning && !status.spoilerText.isEmpty {
contentContainer.attachmentsView.contentHidden = status.spoilerText.isEmpty || Preferences.shared.blurMediaBehindContentWarning
} else {
contentContainer.attachmentsView.contentHidden = false contentContainer.attachmentsView.contentHidden = false
} else {
contentContainer.attachmentsView.contentHidden = true
} }
} else {
contentContainer.attachmentsView.contentHidden = false
} }
let reblogButtonImage: UIImage let reblogButtonImage: UIImage

View File

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

View File

@ -369,13 +369,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
var str: AttributedString = "" var str = AttributedString("\(status.account.displayOrUserName), ")
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 statusState.collapsed ?? false {
if !status.spoilerText.isEmpty { if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText) str += AttributedString(status.spoilerText)
@ -384,37 +378,15 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
str += "collapsed" str += "collapsed"
} else { } else {
str += AttributedString(contentTextView.attributedText) str += AttributedString(contentTextView.attributedText)
if status.attachments.count > 0 {
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"
}
} }
if status.attachments.count > 0 {
// TODO: localize me
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)))") str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
if status.visibility < .unlisted { if status.visibility < .unlisted {
str += AttributedString(", \(status.visibility.displayName)") str += AttributedString(", \(status.visibility.displayName)")
@ -422,6 +394,10 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
if status.localOnly { if status.localOnly {
str += ", local" str += ", local"
} }
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
}
return NSAttributedString(str) return NSAttributedString(str)
} }
set {} set {}

View File

@ -254,13 +254,7 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
var str: AttributedString = "" var str = AttributedString("\(status.account.displayOrUserName), ")
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 statusState.collapsed ?? false {
if !status.spoilerText.isEmpty { if !status.spoilerText.isEmpty {
str += AttributedString(status.spoilerText) str += AttributedString(status.spoilerText)
@ -269,37 +263,15 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
str += "collapsed" str += "collapsed"
} else { } else {
str += AttributedString(contentTextView.attributedText) str += AttributedString(contentTextView.attributedText)
if status.attachments.count > 0 {
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"
}
} }
if status.attachments.count > 0 {
// TODO: localize me
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)))") str += AttributedString(", \(status.createdAt.formatted(.relative(presentation: .numeric)))")
if status.visibility < .unlisted { if status.visibility < .unlisted {
str += AttributedString(", \(status.visibility.displayName)") str += AttributedString(", \(status.visibility.displayName)")
@ -307,6 +279,10 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
if status.localOnly { if status.localOnly {
str += ", local" str += ", local"
} }
if let rebloggerID,
let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {
str += AttributedString(", reblogged by \(reblogger.displayOrUserName)")
}
return NSAttributedString(str) return NSAttributedString(str)
} }
set {} set {}

Some files were not shown because too many files have changed in this diff Show More