Compare commits

...

22 Commits

Author SHA1 Message Date
Shadowfacts 53d43b5707 Update changelog 2023-10-01 22:14:26 -04:00
Shadowfacts b1564d822e Bump version and move to xcconfig to fix warnings 2023-10-01 22:14:01 -04:00
Shadowfacts a8a2f0a26c Add search operators UI on Mastodon 4.2
Closes #433
2023-10-01 21:40:53 -04:00
Shadowfacts 46e1205327 Fix delay before My Profile sidebar item appears on launch 2023-10-01 10:20:45 -04:00
Shadowfacts 6a2de2be55 Make suggested profile cells uniform height on trends screen 2023-10-01 10:15:00 -04:00
Shadowfacts db6ba0c62c Remove navigation mode preference feature flag 2023-10-01 00:14:20 -04:00
Shadowfacts 16029dc161 Fix Appearance > Interface prefs using wrong row background color 2023-10-01 00:12:01 -04:00
Shadowfacts 31a0db014a Improve multi-column layout for suggested profiles 2023-10-01 00:08:00 -04:00
Shadowfacts 5be8005e24 Use two columns for trending links/accounts on wide screens 2023-09-29 17:33:18 -04:00
Shadowfacts ad4e112e96 Fix switching back to previous navigation mode 2023-09-29 17:18:29 -04:00
Shadowfacts 7a2dc7d3c4 Improve readable-width content inset behavior 2023-09-28 21:30:30 -04:00
Shadowfacts 0948371f83 Improve appearance of lists when converting from HTML
Closes #434
2023-09-27 17:35:36 -04:00
Shadowfacts 3ba1a00257 Reconfigure visible updates when refreshing
Closes #300
2023-09-26 09:42:39 -04:00
Shadowfacts 1b42cd7816 Fix cell reuse bug with follow/action notifications 2023-09-26 09:18:01 -04:00
Shadowfacts a2fe0dfb78 Avoid unnecessarily recreating avatar views in notifications cells 2023-09-25 21:44:43 -04:00
Shadowfacts bf1ed57180 Allow authoring local-only posts on Akkoma
Closes #332
2023-09-25 21:23:28 -04:00
Shadowfacts 6821f1b9a0 Don't show doubled "New Post" in window titlebar on macOS
Closes #429
2023-09-24 23:50:08 -04:00
Shadowfacts 7ae741cd83 Fix Live Text control reappearing when swiping between gallery pages with controls hidden
Closes #431
2023-09-24 23:44:40 -04:00
Shadowfacts fe9ad83ddc Fix replies with content warnings showing confirm dialog when unchanged
Closes #430
2023-09-24 23:28:36 -04:00
Shadowfacts 6b7c828cc9 Try to compress videos to fit within instance limits
Closes #425
2023-09-16 14:07:49 -04:00
Shadowfacts 2be1ee19de Improve error message when uploading attachment to Pixelfed fails
See #425
2023-09-16 13:56:46 -04:00
Shadowfacts 3f15a453bd Update to recommended Xcode settings 2023-09-16 13:50:39 -04:00
51 changed files with 1018 additions and 215 deletions

View File

@ -1,5 +1,22 @@
# Changelog
## 2023.8 (104)
Features/Improvements:
- Show search operators on Mastodon 4.2
- Enable composing local-only posts on Akkoma
- Update timestamps after refreshing notifications/timelines
- Improve list appearance in rich text posts
- Improve error message when uploading attachment to Pixelfed fails
- Compress uploaded videos to fit within instance limits
- iPad: Allow switching between split screen and full screen navigation
Bugfixes:
- Fix replies to posts with content warnings always showing confirmation dialog before closing
- Fix Live Text control reappearing when swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- iPad: Fix delay on app launch before "My Profile" sidebar item appears
- macOS: Fix "New Post" window title appearing twice
## 2023.7 (103)
Features/Improvements:
- Add support for iOS 17

View File

@ -72,12 +72,12 @@ class PostService: ObservableObject {
mediaIDs: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil,
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil,
idempotencyKey: draft.id.uuidString
)
}

View File

@ -53,10 +53,11 @@ class ToolbarController: ViewController {
HStack(spacing: 0) {
cwButton
MenuPicker(selection: $draft.visibility, options: visibilityOptions, buttonStyle: .iconOnly)
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
// the button has a bunch of extra space by default, but combined with what we add it's too much
.padding(.horizontal, -8)
.disabled(draft.editedStatusID != nil)
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
localOnlyPicker
@ -118,9 +119,20 @@ class ToolbarController: ViewController {
.hoverEffect()
}
private var visibilityBinding: Binding<Pachyderm.Visibility> {
// On instances that conflate visibliity and local only, we still show two separate controls but don't allow
// changing the visibility when local-only.
if draft.localOnly,
composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility {
return .constant(.public)
} else {
return $draft.visibility
}
}
private var visibilityOptions: [MenuPicker<Pachyderm.Visibility>.Option] {
let visibilities: [Pachyderm.Visibility]
if !controller.parent.mastodonController.instanceFeatures.composeDirectStatuses {
if !composeController.mastodonController.instanceFeatures.composeDirectStatuses {
visibilities = [.public, .unlisted, .private]
} else {
visibilities = Pachyderm.Visibility.allCases

View File

@ -25,6 +25,7 @@ public class Draft: NSManagedObject, Identifiable {
@NSManaged public var contentWarningEnabled: Bool
@NSManaged public var editedStatusID: String?
@NSManaged public var id: UUID
@NSManaged public var initialContentWarning: String?
@NSManaged public var initialText: String
@NSManaged public var inReplyToID: String?
@NSManaged public var language: String? // ISO 639 language code
@ -65,7 +66,7 @@ public class Draft: NSManagedObject, Identifiable {
extension Draft {
public var hasContent: Bool {
(!text.isEmpty && text != initialText) ||
(contentWarningEnabled && !contentWarning.isEmpty) ||
(contentWarningEnabled && !contentWarning.isEmpty && contentWarning != initialContentWarning) ||
attachments.count > 0 ||
poll?.hasContent == true
}

View File

@ -216,7 +216,7 @@ extension DraftAttachment {
options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession {
Self.exportVideoData(session: exportSession, completion: completion)
Self.exportVideoData(session: exportSession, features: features, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
@ -242,7 +242,7 @@ extension DraftAttachment {
completion(.failure(.noVideoExportSession))
return
}
Self.exportVideoData(session: session, completion: completion)
Self.exportVideoData(session: session, features: features, completion: completion)
} else {
let fileData: Data
do {
@ -300,9 +300,12 @@ extension DraftAttachment {
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
if let configuration = features.mediaAttachmentsConfiguration {
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
}
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))

View File

@ -1,11 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22221.1" systemVersion="22G74" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Draft" representedClassName="ComposeUI.Draft" syncable="YES">
<attribute name="accountID" attributeType="String"/>
<attribute name="contentWarning" attributeType="String" defaultValueString=""/>
<attribute name="contentWarningEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="editedStatusID" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="initialContentWarning" optional="YES" attributeType="String"/>
<attribute name="initialText" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="language" optional="YES" attributeType="String"/>

View File

@ -88,6 +88,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.text = text
draft.initialText = text
draft.contentWarning = contentWarning
draft.initialContentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID
draft.visibility = visibility
@ -112,6 +113,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.initialText = source.text
draft.contentWarning = source.spoilerText
draft.contentWarningEnabled = !source.spoilerText.isEmpty
draft.initialContentWarning = source.spoilerText
draft.inReplyToID = inReplyToID
draft.visibility = visibility
draft.localOnly = localOnly

View File

@ -21,16 +21,28 @@ public class InstanceFeatures: ObservableObject {
@Published public private(set) var charsReservedPerURL = 23
@Published public private(set) var maxPollOptionChars: Int?
@Published public private(set) var maxPollOptionsCount: Int?
@Published public private(set) var mediaAttachmentsConfiguration: Instance.MediaAttachmentsConfiguration?
public var localOnlyPosts: Bool {
switch instanceType {
case .mastodon(.hometown(_), _), .mastodon(.glitch, _):
return true
case .pleroma(.akkoma(_)):
return true
default:
return false
}
}
/// Instance types that use a separate visibility to indicate local-only posts.
public var localOnlyPostsVisibility: Bool {
if case .pleroma(.akkoma(_)) = instanceType {
return true
} else {
return false
}
}
public var mastodonAttachmentRestrictions: Bool {
instanceType.isMastodon
}
@ -155,6 +167,10 @@ public class InstanceFeatures: ObservableObject {
}
}
public var searchOperators: Bool {
hasMastodonVersion(4, 2, 0)
}
public init() {
}
@ -211,6 +227,7 @@ public class InstanceFeatures: ObservableObject {
maxPollOptionChars = pollsConfig.maxCharactersPerOption
maxPollOptionsCount = pollsConfig.maxOptions
}
mediaAttachmentsConfiguration = instance.configuration?.mediaAttachments
_featuresUpdated.send()
}

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1500"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -113,6 +113,7 @@ public class Client {
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name
urlRequest.httpBody = request.body.data
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
@ -395,7 +396,7 @@ public class Client {
mediaIDs: [String]? = nil,
sensitive: Bool? = nil,
spoilerText: String? = nil,
visibility: Visibility? = nil,
visibility: String? = nil,
language: String? = nil, // language supported by mastodon and akkoma
pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil,
@ -408,7 +409,7 @@ public class Client {
"in_reply_to_id" => inReplyTo,
"sensitive" => sensitive,
"spoiler_text" => spoilerText,
"visibility" => visibility?.rawValue,
"visibility" => visibility,
"language" => language,
"poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple,

View File

@ -11,7 +11,20 @@ import Foundation
struct MastodonError: Decodable, CustomStringConvertible {
var description: String
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let error = try container.decodeIfPresent(String.self, forKey: .error) {
self.description = error
} else if let message = try container.decodeIfPresent(String.self, forKey: .message) {
self.description = message
} else {
throw DecodingError.keyNotFound(CodingKeys.error, .init(codingPath: container.codingPath, debugDescription: "Missing error or message key"))
}
}
private enum CodingKeys: String, CodingKey {
case description = "error"
case error
// used by pixelfed
case message
}
}

View File

@ -0,0 +1,19 @@
//
// SearchOperatorType.swift
// Pachyderm
//
// Created by Shadowfacts on 10/1/23.
//
import Foundation
public enum SearchOperatorType: String, CaseIterable, Equatable {
case has
case `is`
case language
case from
case before
case during
case after
case `in`
}

View File

@ -10,6 +10,9 @@ import Foundation
import WebURL
public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
public static let localPostVisibility: String = "local"
public let id: String
public let uri: String
public let url: WebURL?
@ -77,7 +80,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility),
s == "local" {
s == Status.localPostVisibility {
// hacky workaround for #332, akkoma describes local posts with a separate visibility
self.visibility = .public
self.localOnly = true

View File

@ -436,7 +436,6 @@ extension Preferences {
public enum FeatureFlag: String, Codable {
case iPadMultiColumn = "ipad-multi-column"
case iPadBrowserNavigation = "ipad-browser-navigation"
case iPadNavigationMode = "ipad-navigation-mode"
}
}

View File

@ -1,2 +1,5 @@
#include "Version.xcconfig"
DEVELOPMENT_TEAM = YOUR_TEAM_ID
BUNDLE_ID_PREFIX = com.example

View File

@ -286,6 +286,9 @@
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6CA6ED129EF6091003EC5DF /* TuskerPreferences */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */; };
D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */; };
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; };
@ -337,6 +340,7 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; };
@ -648,6 +652,7 @@
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; };
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; };
D6B5F3BC2ACA586C00309734 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; };
D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
@ -685,6 +690,9 @@
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerPreferences; path = Packages/TuskerPreferences; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; };
D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSCollectionLayoutSection+Readable.swift"; sourceTree = "<group>"; };
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiColumnCollectionViewLayout.swift; sourceTree = "<group>"; };
D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonSearchController.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; };
@ -746,6 +754,7 @@
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; };
@ -1245,6 +1254,7 @@
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */,
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */,
D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1359,6 +1369,8 @@
isa = PBXGroup;
children = (
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */,
D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */,
);
path = Search;
sourceTree = "<group>";
@ -1449,6 +1461,7 @@
children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */,
D6D706A829498C82000827ED /* Tusker.xcconfig */,
D6B5F3BC2ACA586C00309734 /* Version.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */,
D6BEA243291A0C83002F4D01 /* Duckable */,
D68A76F22953915C001DA1B3 /* TTTKit */,
@ -1499,6 +1512,7 @@
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
@ -1770,8 +1784,9 @@
D6D4DDC4212518A000E1C4BB /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1400;
LastUpgradeCheck = 1500;
ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = {
D6A4531229EF64BA00032932 = {
@ -2077,6 +2092,7 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
@ -2122,6 +2138,7 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
@ -2204,6 +2221,7 @@
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
@ -2217,6 +2235,7 @@
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */,
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */,
@ -2398,7 +2417,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 103;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2406,7 +2425,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.7;
MARKETING_VERSION = "$(MARKETING_VERSION)";
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2464,7 +2483,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2489,7 +2508,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2517,7 +2536,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2545,7 +2564,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2699,7 +2718,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 103;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2707,7 +2726,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.7;
MARKETING_VERSION = "$(MARKETING_VERSION)";
OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
@ -2730,7 +2749,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 103;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2738,7 +2757,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.7;
MARKETING_VERSION = "$(MARKETING_VERSION)";
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2836,7 +2855,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2861,7 +2880,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 100;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES"
version = "1.7">
<BuildAction

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1400"
LastUpgradeVersion = "1500"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -0,0 +1,51 @@
//
// NSCollectionLayoutSection+Readable.swift
// Tusker
//
// Created by Shadowfacts on 9/28/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
extension NSCollectionLayoutSection {
// The .readableContent insets reference has a bunch of weird behavior,
// so just calculate the content inset ourselves.
func readableContentInset(in environment: NSCollectionLayoutEnvironment) {
guard let maximumReadableWidth = environment.maximumReadableWidth else {
return
}
let inset = max(0, (environment.container.contentSize.width - maximumReadableWidth) / 2)
// make sure not to overwrite the vertical insets, which are non-zero for grouped styles
contentInsets.leading = inset
contentInsets.trailing = inset
}
}
extension NSCollectionLayoutEnvironment {
var maximumReadableWidth: CGFloat? {
switch traitCollection.preferredContentSizeCategory {
case .extraSmall:
return 560
case .small:
return 600
case .medium:
return 632
case .large:
return 664
case .extraLarge:
return 744
case .extraExtraLarge:
return 816
case .extraExtraExtraLarge:
return 896
case .accessibilityMedium:
return 1096
default:
// greater accessibility sizes don't have a limit
return nil
}
}
}

View File

@ -37,7 +37,12 @@ struct HTMLConverter {
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace()
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
// Wait until the end and then fill in the unset paragraph styles, to avoid clobbering the list style.
mutAttrString.enumerateAttribute(.paragraphStyle, in: mutAttrString.fullRange, options: .longestEffectiveRangeNotRequired) { value, range, stop in
if value == nil {
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
}
}
return mutAttrString
} else {
@ -56,6 +61,10 @@ struct HTMLConverter {
}
return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
case let node as Element:
if node.tagName() == "ol" || node.tagName() == "ul" {
return attributedTextForList(node, usePreformattedText: usePreformattedText)
}
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
for child in node.getChildNodes() {
var appendEllipsis = false
@ -115,25 +124,6 @@ struct HTMLConverter {
case "pre":
attributed.append(NSAttributedString(string: "\n\n"))
attributed.addAttribute(.font, value: monospaceFont, range: attributed.fullRange)
case "ol", "ul":
attributed.append(NSAttributedString(string: "\n\n"))
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
case "li":
let parentEl = node.parent()!
let parentTag = parentEl.tagName()
let bullet: NSAttributedString
if parentTag == "ol" {
let index = (try? node.elementSiblingIndex()) ?? 0
// we use the monospace digit font so that the periods of all the list items line up
// TODO: this probably breaks with dynamic type
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: monospaceFont, .foregroundColor: color])
} else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font, .foregroundColor: color])
} else {
bullet = NSAttributedString()
}
attributed.insert(bullet, at: 0)
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
default:
break
}
@ -144,5 +134,37 @@ struct HTMLConverter {
}
}
private func attributedTextForList(_ element: Element, usePreformattedText: Bool) -> NSAttributedString {
let list = element.tagName() == "ol" ? OrderedNumberTextList(markerFormat: .decimal, options: 0) : NSTextList(markerFormat: .disc, options: 0)
let paragraphStyle = paragraphStyle.mutableCopy() as! NSMutableParagraphStyle
// I don't like that I can't just use paragraphStyle.textLists, because it makes the list markers
// not use the monospace digit font (it seems to just use whatever font attribute is set for the whole thing),
// and it doesn't right align the list markers.
// Unfortunately, doing it manually means the list markers are incldued in the selectable text.
paragraphStyle.headIndent = 32
paragraphStyle.firstLineHeadIndent = 0
// Use 2 tab stops, one for the list marker, the second for the content.
paragraphStyle.tabStops = [NSTextTab(textAlignment: .right, location: 28), NSTextTab(textAlignment: .natural, location: 32)]
let str = NSMutableAttributedString(string: "")
var item = 1
for child in element.children() where child.tagName() == "li" {
if let childStr = attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText) {
str.append(NSAttributedString(string: "\t\(list.marker(forItemNumber: item))\t", attributes: [
.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedDigitSystemFont(ofSize: 17, weight: .regular)),
]))
str.append(childStr)
str.append(NSAttributedString(string: "\n"))
item += 1
}
}
str.addAttribute(.paragraphStyle, value: paragraphStyle, range: str.fullRange)
return str
}
}
private class OrderedNumberTextList: NSTextList {
override func marker(forItemNumber itemNumber: Int) -> String {
"\(super.marker(forItemNumber: itemNumber))."
}
}

View File

@ -0,0 +1,260 @@
//
// MultiColumnCollectionViewLayout.swift
// Tusker
//
// Created by Shadowfacts on 9/29/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class MultiColumnCollectionViewLayout: UICollectionViewLayout {
private let numberOfColumns: Int
private let spacing: CGFloat
private let minimumColumnWidth: CGFloat
private var effectiveNumberOfColumns: Int!
private var attributes: [MultiColumnLayoutAttributes] = []
private var invalidatedItemIndices: IndexSet = []
var showSectionHeader = false {
didSet {
if showSectionHeader != oldValue {
invalidateLayout()
}
}
}
private var sectionHeaderAttributes: MultiColumnLayoutAttributes?
init(numberOfColumns: Int, columnSpacing: CGFloat, minimumColumnWidth: CGFloat) {
precondition(numberOfColumns >= 1)
self.numberOfColumns = numberOfColumns
self.effectiveNumberOfColumns = nil
self.spacing = columnSpacing
self.minimumColumnWidth = minimumColumnWidth
super.init()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override class var layoutAttributesClass: AnyClass {
MultiColumnLayoutAttributes.self
}
override class var invalidationContextClass: AnyClass {
MultiColumnLayoutInvalidationContext.self
}
override func prepare() {
guard let collectionView else { return }
precondition(collectionView.numberOfSections <= 1)
guard collectionView.numberOfSections == 1 else {
attributes = []
return
}
if effectiveNumberOfColumns == nil {
updateEffectiveNumberOfColumns()
}
var lastAttributesInEachColumn: [MultiColumnLayoutAttributes?] = Array(repeating: nil, count: effectiveNumberOfColumns)
func columnWithMinHeight() -> Int {
var min: Int?
for i in 0..<effectiveNumberOfColumns {
guard let attrs = lastAttributesInEachColumn[i] else {
return i
}
if min == nil || attrs.frame.maxY < lastAttributesInEachColumn[min!]!.frame.maxY {
min = i
}
}
return min!
}
let columnWidth = columnWidthForColumnCount(effectiveNumberOfColumns)
func minXForColumn(_ column: Int) -> CGFloat {
(CGFloat(column) * columnWidth) + ((CGFloat(column) + 1) * spacing)
}
let startY: CGFloat
if showSectionHeader {
let indexPath = IndexPath(item: 0, section: 0)
if sectionHeaderAttributes == nil {
sectionHeaderAttributes = .init(forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, with: indexPath)
}
sectionHeaderAttributes!.frame = CGRect(x: 0, y: 0, width: collectionView.bounds.width, height: 0)
if let view = collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionHeader, at: indexPath) {
let preferred = view.preferredLayoutAttributesFitting(sectionHeaderAttributes!)
sectionHeaderAttributes!.frame.size.height = preferred.frame.height
}
startY = sectionHeaderAttributes!.frame.height
} else {
startY = spacing
}
let numberOfItems = collectionView.numberOfItems(inSection: 0)
for item in 0..<numberOfItems {
let column = columnWithMinHeight()
let itemAttrs: MultiColumnLayoutAttributes
if attributes.count > item {
itemAttrs = attributes[item]
} else {
itemAttrs = MultiColumnLayoutAttributes(forCellWith: IndexPath(item: item, section: 0))
// estimate
itemAttrs.frame.size.height = 200
attributes.append(itemAttrs)
}
itemAttrs.column = column
itemAttrs.frame.size.width = columnWidth
if invalidatedItemIndices.contains(item) {
if let cell = collectionView.cellForItem(at: IndexPath(item: item, section: 0)) {
let preferred = cell.preferredLayoutAttributesFitting(itemAttrs.copy() as! UICollectionViewLayoutAttributes)
itemAttrs.frame.size.height = preferred.frame.height
}
}
if let lastInColumn = lastAttributesInEachColumn[column] {
itemAttrs.frame.origin = CGPoint(
x: lastInColumn.frame.minX,
y: lastInColumn.frame.maxY + spacing
)
} else {
itemAttrs.frame.origin = CGPoint(
x: minXForColumn(column),
y: startY
)
}
lastAttributesInEachColumn[column] = itemAttrs
}
if attributes.count > numberOfItems {
attributes.removeLast(attributes.count - numberOfItems)
}
invalidatedItemIndices = []
}
override var collectionViewContentSize: CGSize {
guard let collectionView else {
return .zero
}
let maxY = attributes.lazy.map(\.frame.maxY).max()
return CGSize(width: collectionView.bounds.width, height: (maxY ?? 0) + spacing)
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
return attributes[indexPath.item]
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
// TODO: optimize this
var attributes = attributes.filter { $0.frame.intersects(rect) }
if showSectionHeader,
let sectionHeaderAttributes,
rect.minY <= sectionHeaderAttributes.frame.maxY {
attributes.append(sectionHeaderAttributes)
}
return attributes
}
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
if elementKind == UICollectionView.elementKindSectionHeader,
showSectionHeader {
return sectionHeaderAttributes
} else {
return nil
}
}
override func indexPathsToInsertForSupplementaryView(ofKind elementKind: String) -> [IndexPath] {
if elementKind == UICollectionView.elementKindSectionHeader {
return [IndexPath(item: 0, section: 0)]
} else {
return []
}
}
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
return newBounds.width != collectionView?.bounds.width
}
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! MultiColumnLayoutInvalidationContext
context.updateEffectiveNumberOfColumns = true
return context
}
// On landscape-to-portrait device rotations (though seemingly only when inside a UISplitViewController), the collection view
// doesn't go through the bounds-change codepath. But we may still need to update the effective number of columns,
// so override this private method (which is called) and configure the invalidation context appropriately.
override func _invalidationContext(forUpdatedLayoutMargins margins: UIEdgeInsets) -> UICollectionViewLayoutInvalidationContext {
let context = super._invalidationContext(forUpdatedLayoutMargins: margins) as! MultiColumnLayoutInvalidationContext
context.updateEffectiveNumberOfColumns = true
return context
}
override func shouldInvalidateLayout(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> Bool {
return preferredAttributes.frame.height != originalAttributes.frame.height
}
override func invalidationContext(forPreferredLayoutAttributes preferredAttributes: UICollectionViewLayoutAttributes, withOriginalAttributes originalAttributes: UICollectionViewLayoutAttributes) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forPreferredLayoutAttributes: preferredAttributes, withOriginalAttributes: originalAttributes)
if let kind = preferredAttributes.representedElementKind {
context.invalidateSupplementaryElements(ofKind: kind, at: [preferredAttributes.indexPath])
} else {
context.invalidateItems(at: [preferredAttributes.indexPath])
}
return context
}
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext) {
let context = context as! MultiColumnLayoutInvalidationContext
for indexPath in context.invalidatedItemIndexPaths ?? [] {
invalidatedItemIndices.insert(indexPath.item)
}
if context.invalidateEverything || context.updateEffectiveNumberOfColumns {
updateEffectiveNumberOfColumns()
}
super.invalidateLayout(with: context)
}
private func columnWidthForColumnCount(_ count: Int) -> CGFloat {
guard let collectionView else { return 0 }
let spacingTotal = spacing * (CGFloat(count) + 1)
return (collectionView.bounds.width - spacingTotal) / CGFloat(count)
}
private func updateEffectiveNumberOfColumns() {
var n = numberOfColumns
while n > 1 && columnWidthForColumnCount(n) < minimumColumnWidth {
n -= 1
}
effectiveNumberOfColumns = n
}
}
private class MultiColumnLayoutAttributes: UICollectionViewLayoutAttributes {
var column: Int = -1
override func copy(with zone: NSZone? = nil) -> Any {
let copy = super.copy(with: zone) as! MultiColumnLayoutAttributes
copy.column = column
return copy
}
}
private class MultiColumnLayoutInvalidationContext: UICollectionViewLayoutInvalidationContext {
var updateEffectiveNumberOfColumns: Bool = false
}

View File

@ -91,7 +91,10 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
}
private func updateTitle(draft: Draft) {
guard let scene = window?.windowScene,
// Don't set the scene title on macOS since it shows both that and the VC title in the window titlebar
#if !targetEnvironment(macCatalyst)
guard !ProcessInfo.processInfo.isiOSAppOnMac,
let scene = window?.windowScene,
let mastodonController = scene.session.mastodonController else {
return
}
@ -101,6 +104,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
} else {
scene.title = "New Post"
}
#endif
}
@objc private func themePrefChanged() {

View File

@ -56,9 +56,7 @@ class AccountFollowsListViewController: UIViewController, CollectionViewControll
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -35,9 +35,7 @@ class AccountListViewController: UIViewController, CollectionViewController {
config.backgroundColor = .appGroupedBackground
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -59,10 +59,12 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
return config
}
// we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if
// the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the
// background color always peeking through the edges
let layout = UICollectionViewCompositionalLayout.list(using: config)
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment)
return section
}
viewRespectsSystemMinimumLayoutMargins = false
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// something about the autoresizing mask breaks resizing the vc
view.translatesAutoresizingMaskIntoConstraints = false

View File

@ -59,14 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = resultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController = MastodonSearchController(searchResultsController: resultsController)
definesPresentationContext = true
navigationItem.searchController = searchController
@ -88,13 +81,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
)
.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) {

View File

@ -34,14 +34,8 @@ class InlineTrendsViewController: UIViewController {
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = UISearchController(searchResultsController: resultsController)
searchController = MastodonSearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -56,7 +56,7 @@
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalHuggingPriority="249" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="8" y="147" width="431" height="170"/>
<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"/>
@ -113,7 +113,7 @@
<resources>
<image name="clock.arrow.circlepath" catalog="system" width="128" height="112"/>
<systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>

View File

@ -14,6 +14,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
weak var mastodonController: MastodonController!
var collectionView: UICollectionView!
private var layout: MultiColumnCollectionViewLayout!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var state = State.unloaded
@ -33,30 +34,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
title = "Suggested Accounts"
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
switch dataSource.sectionIdentifier(for: sectionIndex) {
case nil:
fatalError()
case .loadingIndicator:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.showsSeparators = false
return .list(using: config, layoutEnvironment: environment)
case .accounts:
let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280))
let item = NSCollectionLayoutItem(layoutSize: size)
let item2 = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item, item2])
group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = 16
section.contentInsets = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0)
return section
}
}
layout = MultiColumnCollectionViewLayout(numberOfColumns: 2, columnSpacing: 16, minimumColumnWidth: 320)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.delegate = self
@ -75,21 +53,27 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let accountCell = UICollectionView.CellRegistration<SuggestedProfileCardCollectionViewCell, (String, Suggestion.Source)>(cellNib: UINib(nibName: "SuggestedProfileCardCollectionViewCell", bundle: .main)) { cell, indexPath, item in
cell.delegate = self
cell.updateUI(accountID: item.0, source: item.1)
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(let id, let source):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: (id, source))
}
}
let loadingView = UICollectionView.SupplementaryRegistration<LoadingCollectionViewCell>(elementKind: UICollectionView.elementKindSectionHeader) { view, elementKind, indexPath in
view.indicator.startAnimating()
}
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
if elementKind == UICollectionView.elementKindSectionHeader {
return collectionView.dequeueConfiguredReusableSupplementary(using: loadingView, for: indexPath)
} else {
return nil
}
}
return dataSource
}
override func viewWillAppear(_ animated: Bool) {
@ -107,9 +91,10 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
}
state = .loading
layout.showSectionHeader = true
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator])
snapshot.appendSections([.accounts])
await dataSource.apply(snapshot)
do {
@ -118,6 +103,8 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
await mastodonController.persistentContainer.addAll(accounts: suggestions.map(\.account))
layout.showSectionHeader = false
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems(suggestions.map { .account($0.account.id, $0.source) })
@ -131,6 +118,7 @@ class SuggestedProfilesViewController: UIViewController, CollectionViewControlle
await self?.loadInitial()
}
showToast(configuration: config, animated: true)
layout.showSectionHeader = false
}
}
@ -146,11 +134,9 @@ extension SuggestedProfilesViewController {
extension SuggestedProfilesViewController {
enum Section {
case loadingIndicator
case accounts
}
enum Item: Hashable {
case loadingIndicator
case account(String, Suggestion.Source)
}
}

View File

@ -50,9 +50,17 @@ class TrendingLinksViewController: UIViewController, CollectionViewController {
case .links:
let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(280))
let item = NSCollectionLayoutItem(layoutSize: size)
let item2 = NSCollectionLayoutItem(layoutSize: size)
let group = NSCollectionLayoutGroup.vertical(layoutSize: size, subitems: [item, item2])
let group: NSCollectionLayoutGroup
if let maximumReadableWidth = environment.maximumReadableWidth,
environment.container.contentSize.width >= maximumReadableWidth {
let width = (environment.container.contentSize.width - 48) / 2
group = .horizontal(layoutSize: size, subitems: [
NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(width), heightDimension: .estimated(280))),
NSCollectionLayoutItem(layoutSize: .init(widthDimension: .absolute(width), heightDimension: .estimated(280))),
])
} else {
group = .vertical(layoutSize: size, subitems: [NSCollectionLayoutItem(layoutSize: size)])
}
group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)
group.interItemSpacing = .fixed(16)
let section = NSCollectionLayoutSection(group: group)

View File

@ -62,9 +62,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -16,7 +16,7 @@ protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get }
var activityItemsForSharing: [Any] { get }
var owner: LargeImageViewController? { get set }
func setControlsVisible(_ controlsVisible: Bool)
func setControlsVisible(_ controlsVisible: Bool, animated: Bool)
func grayscaleStateChanged()
}
@ -75,13 +75,11 @@ class LargeImageImageContentView: UIImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented")
}
func setControlsVisible(_ controlsVisible: Bool) {
func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
#if !targetEnvironment(macCatalyst)
if #available(iOS 16.0, *),
let analysisInteraction {
// note: passing animated: true here doesn't seem to do anything by itself as of iOS 16.2 (20C5032e)
// so the LargeImageViewController handles animating, but we still need to pass true here otherwise it doesn't animate
analysisInteraction.setSupplementaryInterfaceHidden(!controlsVisible, animated: true)
analysisInteraction.setSupplementaryInterfaceHidden(!controlsVisible, animated: animated)
}
#endif
}
@ -138,7 +136,7 @@ class LargeImageGifContentView: GIFImageView, LargeImageContentView {
fatalError("init(coder:) has not been implemented")
}
func setControlsVisible(_ controlsVisible: Bool) {
func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
}
func grayscaleStateChanged() {
@ -189,7 +187,7 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
fatalError("init(coder:) has not been implemented")
}
func setControlsVisible(_ controlsVisible: Bool) {
func setControlsVisible(_ controlsVisible: Bool, animated: Bool) {
}
func grayscaleStateChanged() {

View File

@ -282,13 +282,17 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
self.controlsVisible = controlsVisible
if animated {
UIView.animate(withDuration: 0.2) {
self.contentView.setControlsVisible(controlsVisible)
// note: the value of animated: is passed to ImageAnalysisInteractino.setSupplementaryInterfaceHidden which (as of iOS 17.0.2 (21A350)):
// - does not animate with animated:false when wrapped in a UIView.animate block
// - does not animate with animated:true unless also wrapped in a UIView.animate block
self.contentView.setControlsVisible(controlsVisible, animated: true)
self.updateControlsView()
}
if controlsVisible && !descriptionTextView.isHidden {
descriptionTextView.flashScrollIndicators()
}
} else {
contentView.setControlsVisible(controlsVisible, animated: false)
updateControlsView()
}
}

View File

@ -68,9 +68,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -36,7 +36,7 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
fatalError("init(coder:) has not been implemented")
}
func updateUI(item: MainSidebarViewController.Item, account: UserAccountInfo) async {
func updateUI(item: MainSidebarViewController.Item, account: UserAccountInfo) {
var config = defaultContentConfiguration()
config.text = item.title
config.image = UIImage(systemName: item.imageName!)
@ -50,6 +50,12 @@ class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
]
}
Task {
await updateAvatar(account: account)
}
}
private func updateAvatar(account: UserAccountInfo) async {
let mastodonController = MastodonController.getForAccount(account)
guard let account = try? await mastodonController.getOwnAccount(),
let avatar = account.avatar else {

View File

@ -139,9 +139,7 @@ class MainSidebarViewController: UIViewController {
}
let myProfileCell = UICollectionView.CellRegistration<MainSidebarMyProfileCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
Task {
await cell.updateUI(item: item, account: self.mastodonController.accountInfo!)
}
cell.updateUI(item: item, account: self.mastodonController.accountInfo!)
}
let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in

View File

@ -117,6 +117,8 @@ class MainSplitViewController: UISplitViewController {
guard mode != navigationMode else {
return
}
navigationMode = mode
let viewControllers = secondaryNavController.viewControllers
secondaryNavController.viewControllers = []
// Setting viewControllers = [] doesn't remove the VC views from their superviews immediately,

View File

@ -139,20 +139,26 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
mastodonController.persistentContainer.account(for: $0.account.id)
}
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
let imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
avatarStack.addArrangedSubview(imageView)
let visibleAvatars = Array(people.lazy.compactMap(\.avatar).prefix(10))
for (index, avatarURL) in visibleAvatars.enumerated() {
let imageView: CachedImageView
if index < avatarStack.arrangedSubviews.count {
imageView = avatarStack.arrangedSubviews[index] as! CachedImageView
} else {
imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
avatarStack.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
}
imageView.update(for: avatarURL)
}
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map {
$0.widthAnchor.constraint(equalTo: $0.heightAnchor)
})
while avatarStack.arrangedSubviews.count > visibleAvatars.count {
avatarStack.arrangedSubviews.last!.removeFromSuperview()
}
actionLabel.setEmojis(pairs: people.map { ($0.displayOrUserName, $0.emojis) }, identifier: group.id)
// todo: use htmlconverter

View File

@ -116,19 +116,25 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
}, identifier: group.id)
updateTimestamp()
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
let imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
let visibleAvatars = Array(people.lazy.compactMap(\.avatar).prefix(10))
for (index, avatarURL) in visibleAvatars.enumerated() {
let imageView: CachedImageView
if index < avatarStack.arrangedSubviews.count {
imageView = avatarStack.arrangedSubviews[index] as! CachedImageView
} else {
imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
avatarStack.addArrangedSubview(imageView)
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true
}
imageView.update(for: avatarURL)
avatarStack.addArrangedSubview(imageView)
}
NSLayoutConstraint.activate(avatarStack.arrangedSubviews.map {
$0.widthAnchor.constraint(equalTo: $0.heightAnchor)
})
while avatarStack.arrangedSubviews.count > visibleAvatars.count {
avatarStack.arrangedSubviews.last!.removeFromSuperview()
}
}
private func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString {

View File

@ -25,6 +25,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var newer: RequestRange?
private var older: RequestRange?
@ -91,9 +93,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -662,6 +662,13 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
}
extension NotificationsCollectionViewController: UICollectionViewDragDelegate {

View File

@ -90,11 +90,11 @@ struct AppearancePrefsView : View {
@ViewBuilder
private var interfaceSection: some View {
if preferences.hasFeatureFlag(.iPadNavigationMode),
UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Section(header: Text("Interface")) {
WidescreenNavigationPrefsView()
}
.appGroupedListRowBackground()
}
}

View File

@ -32,6 +32,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell?
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private(set) var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) {
@ -89,9 +91,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
return .list(using: config, layoutEnvironment: environment)
} else {
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
}
@ -627,6 +627,13 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
return true
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
}
extension ProfileStatusesViewController: UICollectionViewDragDelegate {

View File

@ -0,0 +1,174 @@
//
// MastodonSearchController.swift
// Tusker
//
// Created by Shadowfacts on 10/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
private let acctRegex = try! NSRegularExpression(pattern: "[a-z0-9_]+(@[a-z0-9\\-\\.]+[a-z0-9]+)?$", options: .caseInsensitive)
private let dateLikeRegex = try! NSRegularExpression(pattern: "\\d{4}(-\\d{2}(-\\d{2})?)?$")
private let languageRegex = try! NSRegularExpression(pattern: "(?:language:)?(\\w{2,3})$", options: .caseInsensitive)
class MastodonSearchController: UISearchController {
override var delegate: UISearchControllerDelegate? {
willSet {
precondition(newValue === self)
}
}
override var searchResultsController: SearchResultsViewController {
super.searchResultsController as! SearchResultsViewController
}
init(searchResultsController: SearchResultsViewController) {
super.init(searchResultsController: searchResultsController)
searchResultsController.tokenHandler = { [unowned self] token, op in
self.addToken(token, operator: op)
}
delegate = self
searchResultsUpdater = searchResultsController
automaticallyShowsSearchResultsController = false
showsSearchResultsController = true
if #available(iOS 16.0, *) {
scopeBarActivation = .onSearchActivation
}
searchBar.autocapitalizationType = .none
searchBar.delegate = self
searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateTokenSuggestions(searchText: String, animated: Bool) {
guard searchResultsController.mastodonController.instanceFeatures.searchOperators else {
return
}
let searchText = searchText.trimmingCharacters(in: .whitespaces)
var suggestions: [(SearchOperatorType, [String])] = []
suggestions.append((.has, ["has:media", "has:poll", "has:embed"].filter {
searchText.isEmpty || $0.contains(searchText)
}))
suggestions.append((.is, ["is:reply", "is:sensitive"].filter {
searchText.isEmpty || $0.contains(searchText)
}))
// TODO: use default language from preferences
var langSuggestions = [String]()
if searchText.isEmpty || "language:en".contains(searchText) {
langSuggestions.append("language:en")
}
if searchText != "en",
let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let identifier = (searchText as NSString).substring(with: match.range(at: 1))
if #available(iOS 16.0, *) {
if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) {
langSuggestions.append("language:\(identifier)")
}
} else if searchText != "en" {
langSuggestions.append("language:\(searchText)")
}
}
suggestions.append((.language, langSuggestions))
var fromSuggestions = [String]()
if searchText.isEmpty || "from:me".contains(searchText) {
fromSuggestions.append("from:me")
}
if searchText != "me",
let match = acctRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let matched = (searchText as NSString).substring(with: match.range)
fromSuggestions.append("from:\(matched)")
}
suggestions.append((.from, fromSuggestions))
let components = Calendar.current.dateComponents([.year, .month], from: Date())
for op in [SearchOperatorType.before, .during, .after] {
if searchText.isEmpty {
suggestions.append((op, ["\(op.rawValue):\(components.year!)-\(components.month!)"]))
} else if let match = dateLikeRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) {
let matched = (searchText as NSString).substring(with: match.range)
suggestions.append((op, ["\(op.rawValue):\(matched)"]))
}
}
suggestions.append((.in, ["in:all", "in:library"].filter {
searchText.isEmpty || $0.contains(searchText)
}))
searchResultsController.updateTokenSuggestions(suggestions, animated: animated)
}
private func addToken(_ token: String, operator: SearchOperatorType) {
let field = searchBar.searchTextField
if field.tokens.contains(where: { ($0.representedObject as? String) == token }) {
return
}
let searchToken = UISearchToken(icon: nil, text: token)
searchToken.representedObject = token
field.insertToken(searchToken, at: field.tokens.count)
field.text = ""
let tokenPos = field.positionOfToken(at: field.tokens.count - 1)
field.selectedTextRange = field.textRange(from: tokenPos, to: field.endOfDocument)
if let requiredScope = `operator`.requiredScope,
let index = searchBar.scopeButtonTitles?.firstIndex(of: requiredScope.title) {
searchBar.selectedScopeButtonIndex = index
searchBar(searchBar, selectedScopeButtonIndexDidChange: index)
}
}
}
extension MastodonSearchController: UISearchControllerDelegate {
func willPresentSearchController(_ searchController: UISearchController) {
updateTokenSuggestions(searchText: "", animated: false)
}
}
extension MastodonSearchController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
updateTokenSuggestions(searchText: searchText, animated: true)
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
searchResultsController.searchBarTextDidEndEditing(searchBar)
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
searchResultsController.searchBar(searchBar, selectedScopeButtonIndexDidChange: selectedScope)
}
}
extension UISearchBar {
var searchQueryWithOperators: String {
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }
let query = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !query.isEmpty {
parts.append(query)
}
return parts.joined(separator: " ")
}
}
private extension SearchOperatorType {
var requiredScope: SearchResultsViewController.Scope? {
switch self {
case .is, .from, .in:
return .posts
default:
return nil
}
}
}

View File

@ -33,6 +33,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
weak var exploreNavigationController: UINavigationController?
weak var delegate: SearchResultsViewControllerDelegate?
var tokenHandler: ((String, SearchOperatorType) -> Void)?
var collectionView: UICollectionView! { view as? UICollectionView }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -42,8 +43,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
/// Whether to limit results to accounts the users is following.
var following: Bool? = nil
let searchSubject = PassthroughSubject<String?, Never>()
var currentQuery: String?
private let searchSubject = PassthroughSubject<String?, Never>()
private var searchCancellable: AnyCancellable?
private var currentQuery: String?
init(mastodonController: MastodonController, scope: Scope = .all) {
self.mastodonController = mastodonController
@ -60,29 +62,43 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.headerMode = .supplementary
switch self.dataSource.sectionIdentifier(for: sectionIndex) {
let sectionIdentifier = self.dataSource.sectionIdentifier(for: sectionIndex)!
switch sectionIdentifier {
case .tokenSuggestions(_):
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .absolute(30))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
section.interGroupSpacing = 8
section.contentInsets = NSDirectionalEdgeInsets(top: sectionIndex == 0 ? 16 : 4, leading: 16, bottom: 4, trailing: 16)
return section
case .loadingIndicator:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.showsSeparators = false
config.headerMode = .none
case .statuses:
return .list(using: config, layoutEnvironment: environment)
case .accounts, .hashtags, .statuses:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
config.headerMode = .supplementary
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
default:
break
if sectionIdentifier == .statuses {
config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
// we don't use the readable content inset here, because it insets the entire cell, rather than just the content
// so the cell backgrounds not being full width looks weird
return .list(using: config, layoutEnvironment: environment)
}
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
// we don't use the readable content inset here, because it insets the entire cell, rather than just the content
// so the cell backgrounds not being full width looks weird
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
@ -97,11 +113,10 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
override func viewDidLoad() {
super.viewDidLoad()
_ = searchSubject
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
searchCancellable = searchSubject
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0 != self.currentQuery }
.sink(receiveValue: performSearch(query:))
.sink { [unowned self] in self.performSearch(query: $0) }
userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id)
@ -115,6 +130,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
config.text = section.displayName
supplementaryView.contentConfiguration = config
}
let tokenSuggestionCell = UICollectionView.CellRegistration<SearchTokenSuggestionCollectionViewCell, String> { cell, indexPath, itemIdentifier in
cell.setText(itemIdentifier)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
@ -132,6 +150,8 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
let cell: UICollectionViewCell
switch itemIdentifier {
case .tokenSuggestion(let value):
return collectionView.dequeueConfiguredReusableCell(using: tokenSuggestionCell, for: indexPath, item: value)
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(let accountID):
@ -172,6 +192,45 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
return super.targetViewController(forAction: action, sender: sender)
}
func updateTokenSuggestions(_ suggestions: [(SearchOperatorType, [String])], animated: Bool) {
var snapshot = dataSource.snapshot()
var prev: Section?
for (op, values) in suggestions {
let section = Section.tokenSuggestions(op)
if values.isEmpty {
if snapshot.sectionIdentifiers.contains(section) {
snapshot.deleteSections([section])
}
} else {
if !snapshot.sectionIdentifiers.contains(section) {
if let prev {
snapshot.insertSections([section], afterSection: prev)
} else if let first = snapshot.sectionIdentifiers.first {
snapshot.insertSections([section], beforeSection: first)
} else {
snapshot.appendSections([section])
}
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: section))
}
snapshot.appendItems(values.map { .tokenSuggestion($0) }, toSection: section)
prev = section
}
}
dataSource.apply(snapshot, animatingDifferences: animated)
}
func removeResults() {
var snapshot = dataSource.snapshot()
removeResults(from: &snapshot)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func removeResults(from snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
snapshot.deleteSections([Section.loadingIndicator, .accounts, .hashtags, .statuses].filter { snapshot.sectionIdentifiers.contains($0) })
}
func loadResults(from source: SearchResultsViewController) {
currentQuery = source.currentQuery
if let sourceDataSource = source.dataSource {
@ -180,16 +239,18 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
}
func performSearch(query: String?) {
guard isViewLoaded else {
guard isViewLoaded,
query != currentQuery else {
return
}
guard let query = query, !query.isEmpty else {
self.dataSource.apply(NSDiffableDataSourceSnapshot<Section, Item>())
removeResults()
return
}
self.currentQuery = query
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
var snapshot = dataSource.snapshot()
removeResults(from: &snapshot)
snapshot.appendSections([.loadingIndicator])
snapshot.appendItems([.loadingIndicator])
dataSource.apply(snapshot)
@ -209,7 +270,8 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
}
private func showSearchResults(_ results: SearchResults) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
var snapshot = dataSource.snapshot()
snapshot.deleteSections([.loadingIndicator])
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
let resultTypes = self.scope.resultTypes
@ -304,7 +366,8 @@ extension SearchResultsViewController {
}
extension SearchResultsViewController {
enum Section: CaseIterable {
enum Section: Hashable {
case tokenSuggestions(SearchOperatorType)
case loadingIndicator
case accounts
case hashtags
@ -312,6 +375,8 @@ extension SearchResultsViewController {
var displayName: String? {
switch self {
case .tokenSuggestions:
return nil
case .loadingIndicator:
return nil
case .accounts:
@ -324,6 +389,7 @@ extension SearchResultsViewController {
}
}
enum Item: Hashable {
case tokenSuggestion(String)
case loadingIndicator
case account(String)
case hashtag(Hashtag)
@ -331,6 +397,9 @@ extension SearchResultsViewController {
func hash(into hasher: inout Hasher) {
switch self {
case let .tokenSuggestion(value):
hasher.combine("tokenSuggestion")
hasher.combine(value)
case .loadingIndicator:
hasher.combine("loadingIndicator")
case let .account(id):
@ -347,6 +416,8 @@ extension SearchResultsViewController {
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.tokenSuggestion(let a), .tokenSuggestion(let b)):
return a == b
case (.loadingIndicator, .loadingIndicator):
return true
case (.account(let a), .account(let b)):
@ -376,6 +447,12 @@ extension SearchResultsViewController: UICollectionViewDelegate {
switch dataSource.itemIdentifier(for: indexPath) {
case nil, .loadingIndicator:
return
case .tokenSuggestion(let value):
guard case .tokenSuggestions(let op) = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
tokenHandler?(value, op)
collectionView.deselectItem(at: indexPath, animated: true)
case let .account(id):
if let delegate {
delegate.selectedSearchResult(account: id)
@ -403,7 +480,7 @@ extension SearchResultsViewController: UICollectionViewDelegate {
return nil
}
switch item {
case .loadingIndicator:
case .loadingIndicator, .tokenSuggestion(_):
return nil
case .account(let id):
return UIContextMenuConfiguration {
@ -436,7 +513,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
let url: URL
let activity: NSUserActivity
switch item {
case .loadingIndicator:
case .loadingIndicator, .tokenSuggestion(_):
return []
case .account(let id):
guard let account = mastodonController.persistentContainer.account(for: id) else {
@ -464,18 +541,29 @@ extension SearchResultsViewController: UICollectionViewDragDelegate {
extension SearchResultsViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
searchSubject.send(searchController.searchBar.text)
searchSubject.send(searchController.searchBar.searchQueryWithOperators)
}
}
extension SearchResultsViewController: UISearchBarDelegate {
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
var snapshot = dataSource.snapshot()
let allSuggestionSections = snapshot.sectionIdentifiers.filter {
if case .tokenSuggestions(_) = $0 {
return true
} else {
return false
}
}
snapshot.deleteSections(allSuggestionSections)
dataSource.apply(snapshot, animatingDifferences: true)
// perform a search immedaitely when the search button is pressed
performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines))
performSearch(query: searchBar.searchQueryWithOperators)
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
let newQuery = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)
let newQuery = searchBar.searchQueryWithOperators
let newScope = Scope.allCases[selectedScope]
if self.scope == .all && currentQuery == newQuery {
self.scope = newScope

View File

@ -0,0 +1,42 @@
//
// SearchTokenSuggestionCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 10/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
label.textColor = .tintColor
contentView.backgroundColor = .tintColor.withAlphaComponent(0.2)
contentView.layer.masksToBounds = true
contentView.layer.cornerRadius = 6
contentView.layer.cornerCurve = .continuous
label.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(label)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setText(_ text: String) {
label.text = text
}
}

View File

@ -60,6 +60,7 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
return config
}
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
let section: NSCollectionLayoutSection
switch dataSource.sectionIdentifier(for: sectionIndex)! {
case .status:
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
@ -71,14 +72,12 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
config.trailingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
}
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section
section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
case .accounts:
return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment)
section = NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment)
}
section.readableContentInset(in: environment)
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self

View File

@ -54,9 +54,7 @@ class StatusEditHistoryViewController: UIViewController, CollectionViewControlle
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)

View File

@ -42,6 +42,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var cancellables = Set<AnyCancellable>()
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>()
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
@ -107,12 +109,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
return config
}
// just setting layout.configuration.contentInsetsReference doesn't work with UICollectionViewCompositionalLayout.list
// Use the sectionProvider closure, because the content inset depends on the environment.
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
section.readableContentInset(in: environment)
return section
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -1317,6 +1317,11 @@ extension TimelineViewController: UICollectionViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
userActivityNeedsUpdate.send()
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {

View File

@ -21,6 +21,8 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
var collectionView: UICollectionView! { get }
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
var reconfigureVisibleItemsOnEndDecelerating: Bool { get set }
}
protocol TimelineLikeCollectionViewSection: Hashable, Sendable {
@ -125,6 +127,18 @@ extension TimelineLikeCollectionViewController {
var config: ToastConfiguration
if let error = error as? Self.Error,
error == .allCaughtUp {
// Reconfigure visible items to update timestamps.
#if targetEnvironment(macCatalyst)
let isRefreshing = false
#else
let isRefreshing = collectionView.refreshControl?.isRefreshing ?? false
#endif
if isRefreshing {
reconfigureVisibleItemsOnEndDecelerating = true
} else {
reconfigureVisibleCells()
}
config = ToastConfiguration(title: "You're all caught up")
config.edge = .top
config.dismissAutomaticallyAfter = 2
@ -204,6 +218,16 @@ extension TimelineLikeCollectionViewController {
await task.value
}
@MainActor
func reconfigureVisibleCells() {
let items = collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) }
if !items.isEmpty {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
}
}
func registerTimelineLikeCells() {
collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator")
collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore")

View File

@ -3,6 +3,7 @@
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void)) {
@try {
@ -13,3 +14,8 @@ NS_INLINE NSException * _Nullable catchNSException(void(^_Nonnull tryBlock)(void
}
return nil;
}
// Define this private method so we can override it from MultiColumnCollectionViewLayout.
@interface UICollectionViewLayout (Tusker_Hacks)
-(UICollectionViewLayoutInvalidationContext *)_invalidationContextForUpdatedLayoutMargins:(UIEdgeInsets)newMargins;
@end

18
Version.xcconfig Normal file
View File

@ -0,0 +1,18 @@
//
// Version.xcconfig
// Tusker
//
// Created by Shadowfacts on 10/1/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
// Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2023.8
CURRENT_PROJECT_VERSION = 104
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Release=
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Dist=