Compare commits

...

67 Commits

Author SHA1 Message Date
Shadowfacts e49725e06d Bump build number and update changelog 2022-12-04 14:57:22 -05:00
Shadowfacts 669404d6f8 Copy local-only status from replied-to post
Closes #280
2022-12-04 14:03:12 -05:00
Shadowfacts 2e21742264 Add Cmd+Enter keyboard shortcut for sending post
Closes #283
2022-12-04 14:01:09 -05:00
Shadowfacts 7763d08816 VoiceOver: Fix not being able to select account from conversation main status cell 2022-12-04 13:51:05 -05:00
Shadowfacts 726be85223 VoiceOver: Fix profile relationship label not being read 2022-12-04 13:51:05 -05:00
Shadowfacts 19bf6cbf18 VoiceOver: Add show profile rotor action to timeline statuses
Closes #285
2022-12-04 13:51:05 -05:00
Shadowfacts df07fa85d5 Fix unsatisfiable constraints warning for ZeroHeightCollectionViewCell 2022-12-04 12:17:31 -05:00
Shadowfacts e3e55de55b Fix hide filter action not working on profiles 2022-12-04 12:11:52 -05:00
Shadowfacts 54857a3bf3 Avoid converting HTML to attributed string twice when displaying a status cell for the first time
Now, when Filterer performs the conversion, the status cell can reuse
the attributed string.
2022-12-04 12:08:22 -05:00
Shadowfacts b28f616e85 Don't apply expired filters 2022-12-04 11:55:46 -05:00
Shadowfacts 97c7104dbc Don't update constraints in StatusContentContainer.setCollapsed unless the state actually changes 2022-12-04 11:14:19 -05:00
Shadowfacts 6501343f24 Reapply filters on when they change 2022-12-04 10:54:02 -05:00
Shadowfacts fabe339215 VoiceOver: Indicate filtered posts, make double tapping expand them 2022-12-03 23:20:19 -05:00
Shadowfacts e1886509d3 Filter statuses on profiles 2022-12-03 23:11:09 -05:00
Shadowfacts 8ad48784d9 Fix V2 filter actions not saving 2022-12-03 23:11:09 -05:00
Shadowfacts 75e9c9f986 Fix home/list filters not applying to lists 2022-12-03 23:11:09 -05:00
Shadowfacts a17afe247c Better filter cell and animation for showing filtered post 2022-12-03 23:11:09 -05:00
Shadowfacts 81abcfcf7b Timeline filtering! 2022-12-03 22:16:43 -05:00
Shadowfacts 7e5d8675c2 Extract HTML to attributed string converter to separate helper 2022-12-03 18:58:19 -05:00
Shadowfacts cde3109203 Rename StatusState to CollapseState 2022-12-03 18:21:49 -05:00
Shadowfacts fcf95ba8c1 Filters view UI tweaks 2022-12-03 15:22:10 -05:00
Shadowfacts f71804f094 Extract filter create/update/delete logic into separate services 2022-12-03 14:40:12 -05:00
Shadowfacts 83ca7f1321 Creating filters UI 2022-12-03 14:40:12 -05:00
Shadowfacts 16a1e4008b V2 filters API, CoreData, and editing UI 2022-12-03 12:29:11 -05:00
Shadowfacts 518a8eba0a Start doing filters UI 2022-12-02 22:03:28 -05:00
Shadowfacts 8d56a6450e Fix mute account time not being 1 week 2022-12-02 21:39:05 -05:00
Shadowfacts 8896bfbc59 Consistent "OK" capitalization 2022-12-02 18:06:15 -05:00
Shadowfacts 4ca57f8c76 Better case-insensitive sorting for lists 2022-12-01 18:26:48 -05:00
Shadowfacts c9fa11cc3b Fetch filters and store in CoreData 2022-11-30 22:16:33 -05:00
Shadowfacts 0247c50650 Fix invalid names being used for persistent store 2022-11-30 21:35:52 -05:00
Shadowfacts eca06cb14a Fix too much space on profile header view above description 2022-11-30 21:13:48 -05:00
Shadowfacts c07e2cfdd8 Add more possibilities to relationship label on profile header 2022-11-30 17:05:18 -05:00
Shadowfacts db7615d26f Fix Edit List Accounts search field being jammed in the corner on iPad 2022-11-30 16:53:11 -05:00
Shadowfacts 2f0acad866 Return to previous item when the selected list/hashtag/instance is removed from the sidebar 2022-11-30 16:47:06 -05:00
Shadowfacts a2b3fc0628 Fix saved/followed hashtag lookups being case-sensitive 2022-11-30 16:46:18 -05:00
Shadowfacts e005b70071 Fix creating list on iPad not showing Edit List screen immediately 2022-11-30 16:34:12 -05:00
Shadowfacts b515664db3 Fix creating list on iPad overwriting previous item navigation stack 2022-11-30 16:34:05 -05:00
Shadowfacts 948eff1f7e Workaround for crash when pressing Cmd+1/2/... on macOS
See #253

The actions won't work, but it's better than crashing :/
2022-11-29 23:19:19 -05:00
Shadowfacts f1a39c2faa Add follow/unfollow hashtag actions 2022-11-29 23:14:36 -05:00
Shadowfacts ab8e498cee Refactor menu actions to allow presenting from menu bar items 2022-11-29 23:14:36 -05:00
Shadowfacts c6da754875 Indicate when a followed hashtag caused a post to appear in the home timeline 2022-11-29 23:14:36 -05:00
Shadowfacts 97d5b955a0 Store followed hashtags
The followed hashtags may not load until after the timeline request
completes, and we want to be able to show the hashtag indicator (or at
least make a best effort attempt) immediately.
2022-11-29 23:14:36 -05:00
Shadowfacts 80f9800fd6 Completely replace all items when jumping to present 2022-11-29 20:53:00 -05:00
Shadowfacts 0485400c1f Tweak how InstanceFeatures is updated 2022-11-29 20:52:39 -05:00
Shadowfacts 811aac35d7 Fix timeline statuses not getting deselected when entering split nav
Closes #275
2022-11-29 10:29:40 -05:00
Shadowfacts a77b090435 Fix mute screen layout on iPad
Closes #276
2022-11-29 10:23:00 -05:00
Shadowfacts 21874b0966 Organize expanded custom emoji picker by category
Closes #223
2022-11-28 22:13:06 -05:00
Shadowfacts 08c63a2f84 Add indicator for locked profiles 2022-11-28 21:53:45 -05:00
Shadowfacts 97f00e9d6f Indicate pending follow requests, feedback on successful async menu actions
Closes #265
2022-11-28 21:41:56 -05:00
Shadowfacts a97a7e0aea Fix attachments disappearing from status cells in certain circumstances 2022-11-28 20:40:24 -05:00
Shadowfacts cf870916c9 Fix links in conversation main status not being activatable with VoiceOver
Closes #272
2022-11-28 19:14:08 -05:00
Shadowfacts 7297566060 Fix some swipe actions getting called off the main thread 2022-11-28 19:14:08 -05:00
Shadowfacts 4f28fec62a Add links/mentions/hashtag to VoiceOver rotor in timelines
Closes #231
2022-11-28 19:14:08 -05:00
Shadowfacts c01bc4d840 Compose screen VoiceOver improvements 2022-11-28 18:40:35 -05:00
Shadowfacts ea6698a2d8 State restoration for non-home timeline pages 2022-11-28 16:33:19 -05:00
Shadowfacts 1e950b5ccb State restoration for presented and edited drafts
Closes #270
2022-11-28 16:09:29 -05:00
Shadowfacts 3e5a3c81b5 Add cache size info to Advanced prefs 2022-11-28 14:05:35 -05:00
Shadowfacts a5506aeab6 Add more tracing for notifications missing statuses
See #274
2022-11-27 21:54:58 -05:00
Shadowfacts 23b76a7276 Better crash messages for sidebar collapse/expand failures 2022-11-27 21:46:21 -05:00
Shadowfacts d8f503351b Limit edit list accounts search to accounts the user follows 2022-11-27 21:44:17 -05:00
Shadowfacts d5887f1f02 Add post edited notifications
Closes #238
2022-11-27 11:50:14 -05:00
Shadowfacts e04cdd16d6 Add preferences for status cell swipe actions
Closes #249
2022-11-26 20:26:26 -05:00
Shadowfacts c256fb4cbd When refreshing timeline, hide activity indicator as soon as loadNewer completes 2022-11-26 17:33:58 -05:00
Shadowfacts 21299c8eb8 Fix error when refreshing timeline with no items 2022-11-26 17:33:07 -05:00
Shadowfacts 527706154a Fix long status table view cells not getting collapsed 2022-11-26 17:28:55 -05:00
Shadowfacts 07c86b6949 Fix gifv attachments not being centered
Closes #271
2022-11-25 13:20:31 -05:00
Shadowfacts 92cf938e99 Fix cells not being deselected in account list and status action account list 2022-11-24 12:30:56 -05:00
111 changed files with 3735 additions and 1014 deletions

View File

@ -1,6 +1,57 @@
# Changelog
## 2022.1 (47)
## 2022.1 (49)
The major new feature of this build is filters! Filters are editable by pressing the Filters button in the top-left corner of the Home tab. Filters are currently applied to timelines and profiles (filtering conversations and notifications will be added in a future build).
Features/Improvements:
- Filters
- Edit/create filters
- Apply filters to timelines and profiles
- Add preference to customize swipe actions on status cells (Preferences -> Appearance -> Leading/Trailing Swipe Actions)
- Show notifications for edited posts
- Add more details to the relationship label on profiles
- "Follows you", "You follow", "You follow each other", and "You block"
- Add state restoration for Federated and Local timelines
- Also fixes an issue where posts from other timelines would appear in the home timeline after a relaunch
- Add state restoration for Compose screen
- Organize expanded custom emoji picker by category
- Add indicator for locked profiles
- Add cache size info to Advanced preferences
- Indicate when a followed hashtag caused a post to appear in the home timeline
- Add hashtag follow/unfollow actions
- Completely replace timeline items after Jump to Present used
- Fixes infinite scroll not being usable after jumping
- iPad/Mac: Add Cmd+Return shortcut for sending post on the Compose screen
- VoiceOver: Hide redundant information on Compose screen
- VocieOver: Add links/mentions/hashtags to actions rotor on statuses
- VoiceOver: Add show profile action to timeline statuses
Bugfixes:
- Fix gifv attachments appearing off-center
- Fix long status on certain screens not getting collapsed
- Fix error when refreshing a timeline with no items
- Fix cells in account list and status action account list not being deselected when navigating back
- Limit search in Edit List screen to accounts the user follows
- Fix attachments disappearing from statuses on the Conversation screen
- Fix extra space gap appearing in profile headers below avatar
- Fix mute duration options not including 1 week
- Hometown: Fix local-only state not being copied from the replied-to post
- iPad: Fix timeline statuses not getting deselected when entering split navigation
- iPad: Fix Mute screen using pointless two-column layout
- iPad: Fix creating a list from the sidebar making the previous tab inaccessible
- iPad: Fix creating a list not showing the Edit List screen automatically
- iPad: Fix selected sidebar item becoming out-of-sync when deleting a list/hashtag/instance
- Mac: Workaround for pressing Cmd+1/2/... crashing
- This prevents the crash, but the actions remain unusable due to a macOS bug
- VoiceOver: Fix escape gesture not working on Compose screen
- VoiceOver: Fix links in conversation main status not being selectable
- VoiceOver: Fix profile relationship not being read
- VoiceOver: Fix not being able to select profile from conversation main status
Known Issues:
- Filters are not applied to notifications and conversations
## 2022.1 (48)
This build is a hotfix for the CW button in the Compose screen not working. The previous build's changelog is attached below.
Bugfixes:

View File

@ -229,21 +229,25 @@ public class Client {
}
// MARK: - Filters
public static func getFilters() -> Request<[Filter]> {
return Request<[Filter]>(method: .get, path: "/api/v1/filters")
public static func getFiltersV1() -> Request<[FilterV1]> {
return Request<[FilterV1]>(method: .get, path: "/api/v1/filters")
}
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request<FilterV1> {
return Request<FilterV1>(method: .post, path: "/api/v1/filters", body: ParametersBody([
"phrase" => phrase,
"irreversible" => irreversible,
"whole_word" => wholeWord,
"expires_at" => expiresAt
"expires_in" => expiresIn,
] + "context" => context.contextStrings))
}
public static func getFilter(id: String) -> Request<Filter> {
return Request<Filter>(method: .get, path: "/api/v1/filters/\(id)")
public static func getFilterV1(id: String) -> Request<FilterV1> {
return Request<FilterV1>(method: .get, path: "/api/v1/filters/\(id)")
}
public static func getFiltersV2() -> Request<[FilterV2]> {
return Request(method: .get, path: "/api/v2/filters")
}
// MARK: - Follows
@ -261,6 +265,10 @@ public class Client {
return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
}
public static func getFollowedHashtags() -> Request<[Hashtag]> {
return Request(method: .get, path: "/api/v1/followed_tags")
}
// MARK: - Lists
public static func getLists() -> Request<[List]> {
return Request<[List]>(method: .get, path: "/api/v1/lists")
@ -315,11 +323,12 @@ public class Client {
}
// MARK: - Search
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil) -> Request<SearchResults> {
public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil, following: Bool? = nil) -> Request<SearchResults> {
return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
"q" => query,
"resolve" => resolve,
"limit" => limit,
"following" => following,
] + "types" => types?.map { $0.rawValue })
}

View File

@ -16,6 +16,7 @@ public class Emoji: Codable {
public let url: WebURL
public let staticURL: WebURL
public let visibleInPicker: Bool
public let category: String?
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
@ -24,6 +25,7 @@ public class Emoji: Codable {
self.url = try container.decode(WebURL.self, forKey: .url)
self.staticURL = try container.decode(WebURL.self, forKey: .staticURL)
self.visibleInPicker = try container.decode(Bool.self, forKey: .visibleInPicker)
self.category = try container.decodeIfPresent(String.self, forKey: .category)
}
private enum CodingKeys: String, CodingKey {
@ -31,6 +33,7 @@ public class Emoji: Codable {
case url
case staticURL = "static_url"
case visibleInPicker = "visible_in_picker"
case category
}
}

View File

@ -1,5 +1,5 @@
//
// Filter.swift
// FilterV1.swift
// Pachyderm
//
// Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation
public class Filter: Decodable {
public struct FilterV1: Decodable {
public let id: String
public let phrase: String
private let context: [String]
@ -22,17 +22,16 @@ public class Filter: Decodable {
}
}
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
"phrase" => (phrase ?? filter.phrase),
"irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord),
"expires_at" => (expiresAt ?? filter.expiresAt)
] + "context" => (context?.contextStrings ?? filter.context)))
public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
"phrase" => phrase,
"whole_word" => wholeWord,
"expires_in" => expiresIn,
] + "context" => context.contextStrings))
}
public static func delete(_ filter: Filter) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)")
public static func delete(_ filterID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
}
private enum CodingKeys: String, CodingKey {
@ -45,16 +44,17 @@ public class Filter: Decodable {
}
}
extension Filter {
public enum Context: String, Decodable {
extension FilterV1 {
public enum Context: String, Decodable, CaseIterable {
case home
case notifications
case `public`
case thread
case account
}
}
extension Array where Element == Filter.Context {
extension Array where Element == FilterV1.Context {
var contextStrings: [String] {
return map { $0.rawValue }
}

View File

@ -0,0 +1,115 @@
//
// FilterV2.swift
// Pachyderm
//
// Created by Shadowfacts on 12/2/22.
//
import Foundation
public struct FilterV2: Decodable {
public let id: String
public let title: String
public let context: [FilterV1.Context]
public let expiresAt: Date?
public let action: Action
public let keywords: [Keyword]
public static func update(
_ filterID: String,
title: String,
context: [FilterV1.Context],
expiresIn: TimeInterval?,
action: Action,
keywords keywordUpdates: [KeywordUpdate]
) -> Request<FilterV2> {
var keywordsParams = [Parameter]()
for (index, update) in keywordUpdates.enumerated() {
switch update {
case .update(id: let id, keyword: let keyword, wholeWord: let wholeWord):
keywordsParams.append("keywords_attributes[\(index)][id]" => id)
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
case .add(keyword: let keyword, wholeWord: let wholeWord):
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
case .destroy(id: let id):
keywordsParams.append("keywords_attributes[\(index)][id]" => id)
keywordsParams.append("keywords_attributes[\(index)][_destroy]" => true)
}
}
return Request(method: .put, path: "/api/v2/filters/\(filterID)", body: ParametersBody([
"title" => title,
"expires_in" => expiresIn,
"filter_action" => action.rawValue,
] + "context" => context.contextStrings + keywordsParams))
}
public static func create(
title: String,
context: [FilterV1.Context],
expiresIn: TimeInterval?,
action: Action,
keywords keywordUpdates: [KeywordUpdate]
) -> Request<FilterV2> {
var keywordsParams = [Parameter]()
for (index, update) in keywordUpdates.enumerated() {
switch update {
case .add(keyword: let keyword, wholeWord: let wholeWord):
keywordsParams.append("keywords_attributes[\(index)][keyword]" => keyword)
keywordsParams.append("keywords_attributes[\(index)][whole_word]" => wholeWord)
default:
fatalError("can only add keywords when creating filter")
}
}
return Request(method: .post, path: "/api/v2/filters", body: ParametersBody([
"title" => title,
"expires_in" => expiresIn,
"filter_action" => action.rawValue,
] + "context" => context.contextStrings + keywordsParams))
}
private enum CodingKeys: String, CodingKey {
case id
case title
case context
case expiresAt = "expires_at"
case action = "filter_action"
case keywords
}
}
extension FilterV2 {
public enum Action: String, Decodable, Hashable, CaseIterable {
case warn
case hide
}
}
extension FilterV2 {
public struct Keyword: Decodable {
public let id: String
public let keyword: String
public let wholeWord: Bool
public init(id: String, keyword: String, wholeWord: Bool) {
self.id = id
self.keyword = keyword
self.wholeWord = wholeWord
}
private enum CodingKeys: String, CodingKey {
case id
case keyword
case wholeWord = "whole_word"
}
}
}
extension FilterV2 {
public enum KeywordUpdate {
case update(id: String, keyword: String, wholeWord: Bool)
case add(keyword: String, wholeWord: Bool)
case destroy(id: String)
}
}

View File

@ -15,11 +15,14 @@ public class Hashtag: Codable {
public let url: WebURL
/// Only present when returned from the trending hashtags endpoint
public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in
public let following: Bool?
public init(name: String, url: URL) {
self.name = name
self.url = WebURL(url)!
self.history = nil
self.following = nil
}
public required init(from decoder: Decoder) throws {
@ -28,6 +31,7 @@ public class Hashtag: Codable {
// pixelfed (possibly others) don't fully escape special characters in the hashtag url
self.url = try container.decode(WebURL.self, forKey: .url)
self.history = try container.decodeIfPresent([History].self, forKey: .history)
self.following = try container.decodeIfPresent(Bool.self, forKey: .following)
}
public func encode(to encoder: Encoder) throws {
@ -35,12 +39,22 @@ public class Hashtag: Codable {
try container.encode(name, forKey: .name)
try container.encode(url, forKey: .url)
try container.encodeIfPresent(history, forKey: .history)
try container.encodeIfPresent(following, forKey: .following)
}
public static func follow(name: String) -> Request<Hashtag> {
return Request(method: .post, path: "/api/v1/tags/\(name)/follow")
}
public static func unfollow(name: String) -> Request<Hashtag> {
return Request(method: .post, path: "/api/v1/tags/\(name)/unfollow")
}
private enum CodingKeys: String, CodingKey {
case name
case url
case history
case following
}
}

View File

@ -56,6 +56,7 @@ extension Notification {
case follow
case followRequest = "follow_request"
case poll
case update
case unknown
}
}

View File

@ -8,7 +8,7 @@
import Foundation
public enum Timeline {
public enum Timeline: Equatable {
case home
case `public`(local: Bool)
case tag(hashtag: String)

View File

@ -42,6 +42,10 @@ extension String {
}
}
static func =>(name: String, value: TimeInterval?) -> Parameter {
return name => (value == nil ? nil : Int(value!))
}
static func =>(name: String, focus: (Float, Float)?) -> Parameter {
guard let focus = focus else { return Parameter(name: name, value: nil) }
return Parameter(name: name, value: "\(focus.0),\(focus.1)")

View File

@ -1,5 +1,5 @@
//
// StatusState.swift
// CollapseState.swift
// Pachyderm
//
// Created by Shadowfacts on 11/24/19.
@ -8,7 +8,7 @@
import Foundation
public class StatusState: Equatable {
public class CollapseState: Equatable {
public var collapsible: Bool?
public var collapsed: Bool?
@ -21,8 +21,8 @@ public class StatusState: Equatable {
self.collapsed = collapsed
}
public func copy() -> StatusState {
return StatusState(collapsible: self.collapsible, collapsed: self.collapsed)
public func copy() -> CollapseState {
return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
}
public func hash(into hasher: inout Hasher) {
@ -30,11 +30,11 @@ public class StatusState: Equatable {
hasher.combine(collapsed)
}
public static var unknown: StatusState {
StatusState(collapsible: nil, collapsed: nil)
public static var unknown: CollapseState {
CollapseState(collapsible: nil, collapsed: nil)
}
public static func == (lhs: StatusState, rhs: StatusState) -> Bool {
public static func == (lhs: CollapseState, rhs: CollapseState) -> Bool {
lhs.collapsible == rhs.collapsible && lhs.collapsed == rhs.collapsed
}
}

View File

@ -12,7 +12,7 @@ public struct NotificationGroup: Identifiable, Hashable {
public private(set) var notifications: [Notification]
public let id: String
public let kind: Notification.Kind
public let statusState: StatusState?
public let statusState: CollapseState?
init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil }

View File

@ -29,11 +29,11 @@ public protocol DuckableViewControllerDelegate: AnyObject {
extension UIViewController {
@available(iOS 16.0, *)
public func presentDuckable(_ viewController: DuckableViewController) -> Bool {
public func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool = false) -> Bool {
var cur: UIViewController? = self
while let vc = cur {
if let container = vc as? DuckableContainerViewController {
container.presentDuckable(viewController, animated: true, completion: nil)
container.presentDuckable(viewController, animated: animated, isDucked: isDucked, completion: nil)
return true
} else {
cur = vc.parent

View File

@ -17,6 +17,14 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
private var bottomConstraint: NSLayoutConstraint!
private(set) var state = State.idle
public var duckedViewController: DuckableViewController? {
if case .ducked(let vc, placeholder: _) = state {
return vc
} else {
return nil
}
}
public init(child: UIViewController) {
self.child = child
@ -50,7 +58,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
])
}
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
func presentDuckable(_ viewController: DuckableViewController, animated: Bool, isDucked: Bool, completion: (() -> Void)?) {
guard case .idle = state else {
if animated,
case .ducked(_, placeholder: let placeholder) = state {
@ -69,8 +77,13 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
return
}
state = .presentingDucked(viewController, isFirstPresentation: true)
doPresentDuckable(viewController, animated: animated, completion: completion)
if isDucked {
state = .ducked(viewController, placeholder: createPlaceholderForDuckedViewController(viewController))
configureChildForDuckedPlaceholder()
} else {
state = .presentingDucked(viewController, isFirstPresentation: true)
doPresentDuckable(viewController, animated: animated, completion: completion)
}
}
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
@ -79,9 +92,7 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
nav.modalPresentationStyle = .custom
nav.transitioningDelegate = self
present(nav, animated: animated) {
self.bottomConstraint.isActive = false
self.bottomConstraint = self.child.view.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
self.bottomConstraint.isActive = true
self.configureChildForDuckedPlaceholder()
completion?()
}
}
@ -127,10 +138,18 @@ public class DuckableContainerViewController: UIViewController, DuckableViewCont
}
let placeholder = createPlaceholderForDuckedViewController(viewController)
state = .ducked(viewController, placeholder: placeholder)
configureChildForDuckedPlaceholder()
dismiss(animated: true)
}
private func configureChildForDuckedPlaceholder() {
bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -detentHeight - 4)
bottomConstraint.isActive = true
child.view.layer.cornerRadius = duckedCornerRadius
child.view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
child.view.layer.masksToBounds = true
dismiss(animated: true)
}
@objc func unduckViewController() {
@ -191,7 +210,10 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
@available(iOS 16.0, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
let snapshot = child.view.snapshotView(afterScreenUpdates: false)!
guard let snapshot = child.view.snapshotView(afterScreenUpdates: false) else {
setOverrideTraitCollection(UITraitCollection(userInterfaceLevel: .elevated), forChild: child)
return
}
snapshot.translatesAutoresizingMaskIntoConstraints = false
self.view.addSubview(snapshot)
NSLayoutConstraint.activate([

View File

@ -44,6 +44,30 @@
D61DC84628F498F200B82C6E /* Logging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84528F498F200B82C6E /* Logging.swift */; };
D61DC84B28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */; };
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61DC84C28F500D200B82C6E /* ProfileViewController.swift */; };
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */; };
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */; };
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */; };
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */; };
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F758F29353B4300C0B37F /* FileManager+Size.swift */; };
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759129365C6C00C0B37F /* CollectionViewController.swift */; };
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */; };
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */; };
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759829384D4D00C0B37F /* FiltersView.swift */; };
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759A29384F9C00C0B37F /* FilterMO.swift */; };
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759C2938574B00C0B37F /* FilterRow.swift */; };
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */; };
D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */; };
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */; };
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */; };
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */; };
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75AE293AF50C00C0B37F /* EditedFilter.swift */; };
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B0293BD85300C0B37F /* CreateFilterService.swift */; };
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */; };
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */; };
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B6293C119700C0B37F /* Filterer.swift */; };
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */; };
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BA293C183100C0B37F /* HTMLConverter.swift */; };
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BC293D099600C0B37F /* Lazy.swift */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
@ -286,7 +310,7 @@
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */; };
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */; };
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */; };
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DFC69F242C4CCC00ACC392 /* Weak.swift */; };
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E0DC8D216EDF1E00369478 /* Previewing.swift */; };
D6E343AB265AAD6B00C4AA01 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AA265AAD6B00C4AA01 /* Media.xcassets */; };
D6E343AD265AAD6B00C4AA01 /* ActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E343AC265AAD6B00C4AA01 /* ActionViewController.swift */; };
@ -311,8 +335,6 @@
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */; };
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */; };
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54F291F058600F496A8 /* CreateListService.swift */; };
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A551291F098700F496A8 /* RenameListService.swift */; };
D6F6A554291F0D9600F496A8 /* DeleteListService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A553291F0D9600F496A8 /* DeleteListService.swift */; };
@ -406,6 +428,30 @@
D61DC84528F498F200B82C6E /* Logging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logging.swift; sourceTree = "<group>"; };
D61DC84A28F4FD2000B82C6E /* ProfileHeaderCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderCollectionViewCell.swift; sourceTree = "<group>"; };
D61DC84C28F500D200B82C6E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSwipeAction.swift; sourceTree = "<group>"; };
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeActionsPrefsView.swift; sourceTree = "<group>"; };
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusUpdatedNotificationTableViewCell.swift; sourceTree = "<group>"; };
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = StatusUpdatedNotificationTableViewCell.xib; sourceTree = "<group>"; };
D61F758F29353B4300C0B37F /* FileManager+Size.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Size.swift"; sourceTree = "<group>"; };
D61F759129365C6C00C0B37F /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = "<group>"; };
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedHashtag.swift; sourceTree = "<group>"; };
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleFollowHashtagService.swift; sourceTree = "<group>"; };
D61F759829384D4D00C0B37F /* FiltersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiltersView.swift; sourceTree = "<group>"; };
D61F759A29384F9C00C0B37F /* FilterMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterMO.swift; sourceTree = "<group>"; };
D61F759C2938574B00C0B37F /* FilterRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterRow.swift; sourceTree = "<group>"; };
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparator.swift; sourceTree = "<group>"; };
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemiCaseSensitiveComparatorTests.swift; sourceTree = "<group>"; };
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditFilterView.swift; sourceTree = "<group>"; };
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterKeywordMO.swift; sourceTree = "<group>"; };
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Filter+Helpers.swift"; sourceTree = "<group>"; };
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditedFilter.swift; sourceTree = "<group>"; };
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateFilterService.swift; sourceTree = "<group>"; };
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateFilterService.swift; sourceTree = "<group>"; };
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteFilterService.swift; sourceTree = "<group>"; };
D61F75B6293C119700C0B37F /* Filterer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filterer.swift; sourceTree = "<group>"; };
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroHeightCollectionViewCell.swift; sourceTree = "<group>"; };
D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = "<group>"; };
D61F75BC293D099600C0B37F /* Lazy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lazy.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
@ -656,7 +702,7 @@
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelationshipMO.swift; sourceTree = "<group>"; };
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeakArray.swift; sourceTree = "<group>"; };
D6DFC69F242C4CCC00ACC392 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = "<group>"; };
D6E0DC8D216EDF1E00369478 /* Previewing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Previewing.swift; sourceTree = "<group>"; };
D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OpenInTusker.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D6E343AA265AAD6B00C4AA01 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
@ -684,8 +730,6 @@
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.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>"; };
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchResultsContainerViewController.swift; sourceTree = "<group>"; };
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchFollowingViewController.swift; sourceTree = "<group>"; };
D6F6A54F291F058600F496A8 /* CreateListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateListService.swift; sourceTree = "<group>"; };
D6F6A551291F098700F496A8 /* RenameListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameListService.swift; sourceTree = "<group>"; };
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteListService.swift; sourceTree = "<group>"; };
@ -763,6 +807,7 @@
D626493423BD94CE00612E6E /* CompositionAttachmentData.swift */,
D677284D24ECC01D00C732D3 /* Draft.swift */,
D627FF75217E923E00CC0648 /* DraftsManager.swift */,
D61F75AE293AF50C00C0B37F /* EditedFilter.swift */,
);
path = Models;
sourceTree = "<group>";
@ -776,6 +821,16 @@
path = "Instance Cell";
sourceTree = "<group>";
};
D61F759729384D4200C0B37F /* Filters */ = {
isa = PBXGroup;
children = (
D61F759829384D4D00C0B37F /* FiltersView.swift */,
D61F759C2938574B00C0B37F /* FilterRow.swift */,
D61F75A4293ABD6F00C0B37F /* EditFilterView.swift */,
);
path = Filters;
sourceTree = "<group>";
};
D623A53B2635F4E20095BD04 /* Poll */ = {
isa = PBXGroup;
children = (
@ -849,8 +904,6 @@
children = (
D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */,
D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */,
D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */,
D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */,
);
path = Lists;
sourceTree = "<group>";
@ -875,6 +928,9 @@
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
D61F75932936F0DA00C0B37F /* FollowedHashtag.swift */,
D61F759A29384F9C00C0B37F /* FilterMO.swift */,
D61F75AA293AF11400C0B37F /* FilterKeywordMO.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
);
@ -904,6 +960,7 @@
D6F2E960249E772F005846BB /* Crash Reporter */,
D627943C23A5635D00D38C68 /* Explore */,
D6A4DCC92553666600D9DE31 /* Fast Account Switcher */,
D61F759729384D4200C0B37F /* Filters */,
D641C788213DD86D004B4513 /* Large Image */,
D627944B23A9A02400D38C68 /* Lists */,
D641C782213DD7F0004B4513 /* Main */,
@ -1031,6 +1088,7 @@
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */,
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */,
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */,
@ -1081,6 +1139,8 @@
D64BC18E23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.xib */,
D662AEED263A3B880082A153 /* PollFinishedTableViewCell.swift */,
D662AEEE263A3B880082A153 /* PollFinishedTableViewCell.xib */,
D61F758B2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift */,
D61F758C2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib */,
);
path = Notifications;
sourceTree = "<group>";
@ -1133,6 +1193,7 @@
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */,
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */,
D6BC9DB4232D4CE3002CA326 /* NotificationsMode.swift */,
D61F75872932DB6000C0B37F /* StatusSwipeAction.swift */,
);
path = Preferences;
sourceTree = "<group>";
@ -1160,6 +1221,8 @@
D62E9984279CA23900C26176 /* URLSession+Development.swift */,
D6ADB6ED28EA74E8009924AB /* UIView+Configure.swift */,
D63CC70F2911F1E4000E19DE /* UIScrollView+Top.swift */,
D61F758F29353B4300C0B37F /* FileManager+Size.swift */,
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1290,6 +1353,7 @@
D620483523D38075008A63EF /* ContentTextView.swift */,
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */,
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
@ -1348,6 +1412,7 @@
D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */,
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */,
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */,
D61F759129365C6C00C0B37F /* CollectionViewController.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -1396,17 +1461,21 @@
D6D4DDDB212518A200E1C4BB /* Info.plist */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D61F75BC293D099600C0B37F /* Lazy.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
D6895DE828D962C2006341DA /* TimelineLikeController.swift */,
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */,
D6DFC69F242C4CCC00ACC392 /* WeakArray.swift */,
D6DFC69F242C4CCC00ACC392 /* Weak.swift */,
D63D8DF32850FE7A008D95E1 /* ViewTags.swift */,
D6D4DDD6212518A200E1C4BB /* Assets.xcassets */,
D6AEBB3F2321640F00E5038B /* Activities */,
@ -1434,6 +1503,7 @@
D62FF04723D7CDD700909D6E /* AttributedStringHelperTests.swift */,
D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */,
D6114E1627F8BB210080E273 /* VersionTests.swift */,
D61F75A029396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift */,
D6D4DDE6212518A200E1C4BB /* Info.plist */,
);
path = TuskerTests;
@ -1514,6 +1584,10 @@
D6F6A54F291F058600F496A8 /* CreateListService.swift */,
D6F6A551291F098700F496A8 /* RenameListService.swift */,
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
D61F75B2293BD89C00C0B37F /* UpdateFilterService.swift */,
D61F75B4293BD97400C0B37F /* DeleteFilterService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1698,6 +1772,7 @@
D667E5E12134937B0057A976 /* TimelineStatusTableViewCell.xib in Resources */,
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */,
D662AEF0263A3B880082A153 /* PollFinishedTableViewCell.xib in Resources */,
D61F758E2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.xib in Resources */,
D6A4DCCD2553667800D9DE31 /* FastAccountSwitcherViewController.xib in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1783,7 +1858,6 @@
D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */,
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */,
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */,
D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */,
D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */,
D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */,
D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */,
@ -1791,8 +1865,11 @@
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */,
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */,
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */,
D61F759229365C6C00C0B37F /* CollectionViewController.swift in Sources */,
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */,
D68E525D24A3E8F00054355A /* SearchViewController.swift in Sources */,
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */,
D61F75A5293ABD6F00C0B37F /* EditFilterView.swift in Sources */,
D6AEBB412321642700E5038B /* SendMesasgeActivity.swift in Sources */,
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */,
D6ADB6EC28EA73CB009924AB /* StatusContentContainer.swift in Sources */,
@ -1805,12 +1882,14 @@
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D62275AA24F1E01C00B82A16 /* ComposeTextView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
D667E5F52135BCD50057A976 /* ConversationTableViewController.swift in Sources */,
D6C7D27D22B6EBF800071952 /* AttachmentsContainerView.swift in Sources */,
D61F75942936F0DA00C0B37F /* FollowedHashtag.swift in Sources */,
D63CC7122911F57C000E19DE /* StatusBarTappableViewController.swift in Sources */,
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */,
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */,
@ -1846,9 +1925,11 @@
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63CC70C2910AADB000E19DE /* TuskerSceneDelegate.swift in Sources */,
D61F759D2938574B00C0B37F /* FilterRow.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D61F758A2932E1FC00C0B37F /* SwipeActionsPrefsView.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
@ -1872,6 +1953,7 @@
D62275A024F1677200B82A16 /* ComposeHostingController.swift in Sources */,
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */,
D6BEA249291C6118002F4D01 /* DraftsView.swift in Sources */,
D61F75AD293AF39000C0B37F /* Filter+Helpers.swift in Sources */,
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */,
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */,
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
@ -1889,6 +1971,7 @@
D6DF95C12533F5DE0027A9B6 /* RelationshipMO.swift in Sources */,
D6ADB6EE28EA74E8009924AB /* UIView+Configure.swift in Sources */,
D623A5412635FB3C0095BD04 /* PollOptionView.swift in Sources */,
D61F75B1293BD85300C0B37F /* CreateFilterService.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D6AEBB4A23216F0400E5038B /* UnfollowAccountActivity.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
@ -1918,16 +2001,19 @@
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */,
D62E9985279CA23900C26176 /* URLSession+Development.swift in Sources */,
D622757424EDF1CD00B82A16 /* ComposeAttachmentsList.swift in Sources */,
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */,
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */,
D6895DE928D962C2006341DA /* TimelineLikeController.swift in Sources */,
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
D61F759029353B4300C0B37F /* FileManager+Size.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
@ -1944,6 +2030,7 @@
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
D6BEA24B291C6A2B002F4D01 /* AlertWithData.swift in Sources */,
D61ABEFE28F1C92600B29151 /* FavoriteService.swift in Sources */,
D61F75AB293AF11400C0B37F /* FilterKeywordMO.swift in Sources */,
D663626221360B1900C9CBA2 /* Preferences.swift in Sources */,
D626493823C0FD0000612E6E /* AllPhotosTableViewCell.swift in Sources */,
D627943B23A55BA600D38C68 /* NavigableTableViewCell.swift in Sources */,
@ -1952,6 +2039,7 @@
D6C82B5625C5F3F20017F1E6 /* ExpandThreadTableViewCell.swift in Sources */,
D686BBE324FBF8110068E6AA /* WrappedProgressView.swift in Sources */,
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeAction.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
@ -1972,11 +2060,15 @@
D6262C9A28D01C4B00390C1F /* LoadingTableViewCell.swift in Sources */,
D68C2AE325869BAB00548EFF /* AuxiliarySceneDelegate.swift in Sources */,
D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */,
D61F758D2933C69C00C0B37F /* StatusUpdatedNotificationTableViewCell.swift in Sources */,
D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */,
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */,
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */,
D61F75962937037800C0B37F /* ToggleFollowHashtagService.swift in Sources */,
D61F759929384D4D00C0B37F /* FiltersView.swift in Sources */,
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
@ -1988,8 +2080,9 @@
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
D61F75B5293BD97400C0B37F /* DeleteFilterService.swift in Sources */,
D66362752137068A00C9CBA2 /* Visibility+Helpers.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* WeakArray.swift in Sources */,
D6DFC6A0242C4CCC00ACC392 /* Weak.swift in Sources */,
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */,
D61ABEF828EFC3F900B29151 /* ProfileStatusesViewController.swift in Sources */,
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */,
@ -1999,6 +2092,8 @@
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
D6F6A5592920676800F496A8 /* ComposeToolbar.swift in Sources */,
@ -2019,7 +2114,6 @@
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */,
@ -2034,6 +2128,7 @@
D663625F2135C75500C9CBA2 /* ConversationMainStatusTableViewCell.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
D61F75B3293BD89C00C0B37F /* UpdateFilterService.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
D6A6C10525B6138A00298D0F /* StatusTablePrefetching.swift in Sources */,
@ -2050,6 +2145,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D61F75A129396DE200C0B37F /* SemiCaseSensitiveComparatorTests.swift in Sources */,
D62FF04823D7CDD700909D6E /* AttributedStringHelperTests.swift in Sources */,
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */,
D6114E1727F8BB210080E273 /* VersionTests.swift in Sources */,
@ -2195,7 +2291,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2263,7 +2359,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2413,7 +2509,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2442,7 +2538,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2552,7 +2648,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2579,7 +2675,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 48;
CURRENT_PROJECT_VERSION = 49;
DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;

View File

@ -0,0 +1,41 @@
//
// CreateFilterService.swift
// Tusker
//
// Created by Shadowfacts on 12/3/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
@MainActor
class CreateFilterService {
private let filter: EditedFilter
private let mastodonController: MastodonController
init(filter: EditedFilter, mastodonController: MastodonController) {
self.filter = filter
self.mastodonController = mastodonController
}
func run() async throws {
let updateFrom: AnyFilter
if mastodonController.instanceFeatures.filtersV2 {
let updates = filter.keywords.map {
FilterV2.KeywordUpdate.add(keyword: $0.keyword, wholeWord: $0.wholeWord)
}
let req = FilterV2.create(title: filter.title!, context: filter.contexts, expiresIn: filter.expiresIn, action: filter.action, keywords: updates)
let (updated, _) = try await mastodonController.run(req)
updateFrom = .v2(updated)
} else {
let req = Client.createFilterV1(phrase: filter.keywords.first!.keyword, context: filter.contexts, irreversible: nil, wholeWord: filter.keywords.first!.wholeWord, expiresIn: filter.expiresIn)
let (updated, _) = try await mastodonController.run(req)
updateFrom = .v1(updated)
}
let context = mastodonController.persistentContainer.viewContext
let mo = FilterMO(context: context)
mo.updateFrom(apiFilter: updateFrom, context: context)
mastodonController.persistentContainer.save(context: context)
}
}

View File

@ -0,0 +1,29 @@
//
// DeleteFilterService.swift
// Tusker
//
// Created by Shadowfacts on 12/3/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
@MainActor
class DeleteFilterService {
private let filter: FilterMO
private let mastodonController: MastodonController
init(filter: FilterMO, mastodonController: MastodonController) {
self.filter = filter
self.mastodonController = mastodonController
}
func run() async throws {
let req = FilterV1.delete(filter.id)
_ = try await mastodonController.run(req)
let context = mastodonController.persistentContainer.viewContext
context.delete(filter)
mastodonController.persistentContainer.save(context: context)
}
}

View File

@ -51,11 +51,11 @@ struct InstanceFeatures {
}
var trendingStatusesAndLinks: Bool {
instanceType.isMastodon && hasVersion(3, 5, 0)
instanceType.isMastodon && hasMastodonVersion(3, 5, 0)
}
var reblogVisibility: Bool {
(instanceType.isMastodon && hasVersion(2, 8, 0))
(instanceType.isMastodon && hasMastodonVersion(2, 8, 0))
|| (instanceType.isPleroma && hasPleromaVersion(2, 0, 0))
}
@ -84,6 +84,14 @@ struct InstanceFeatures {
}
}
var canFollowHashtags: Bool {
hasMastodonVersion(4, 0, 0)
}
var filtersV2: Bool {
hasMastodonVersion(4, 0, 0)
}
mutating func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased()
if ver.contains("glitch") {
@ -91,11 +99,20 @@ struct InstanceFeatures {
} else if nodeInfo?.software.name == "hometown" {
var mastoVersion: Version?
var hometownVersion: Version?
// like "1.0.6+3.5.2"
let parts = ver.split(separator: "+")
if parts.count == 2 {
mastoVersion = Version(string: String(parts[1]))
hometownVersion = Version(string: String(parts[0]))
if parts.count == 2,
let first = Version(string: String(parts[0])) {
if first > Version(1, 0, 8) {
// like 3.5.5+hometown-1.0.9
mastoVersion = first
if parts[1].starts(with: "hometown-") {
hometownVersion = Version(string: String(parts[1][parts[1].index(parts[1].startIndex, offsetBy: "hometown-".count + 1)...]))
}
} else {
// like "1.0.6+3.5.2"
hometownVersion = first
mastoVersion = Version(string: String(parts[1]))
}
} else {
mastoVersion = Version(string: ver)
}
@ -121,7 +138,7 @@ struct InstanceFeatures {
maxStatusChars = instance.maxStatusCharacters ?? 500
}
func hasVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
func hasMastodonVersion(_ major: Int, _ minor: Int, _ patch: Int) -> Bool {
if case .mastodon(_, .some(let version)) = instanceType {
return version >= Version(major, minor, patch)
} else {

View File

@ -8,6 +8,7 @@
import Foundation
import Pachyderm
import Combine
class MastodonController: ObservableObject {
@ -47,7 +48,11 @@ class MastodonController: ObservableObject {
@Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var instanceFeatures = InstanceFeatures()
@Published private(set) var lists: [List] = []
private(set) var customEmojis: [Emoji]?
@Published private(set) var customEmojis: [Emoji]?
@Published private(set) var followedHashtags: [FollowedHashtag] = []
@Published private(set) var filters: [FilterMO] = []
private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask?
@ -61,6 +66,29 @@ class MastodonController: ObservableObject {
self.accountInfo = nil
self.client = Client(baseURL: instanceURL, session: .appDefault)
self.transient = transient
$instance
.combineLatest($nodeInfo)
.compactMap { (instance, nodeInfo) in
if let instance {
return (instance, nodeInfo)
} else {
return nil
}
}
.sink { [unowned self] (instance, nodeInfo) in
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
$instanceFeatures
.filter { [unowned self] in $0.canFollowHashtags && self.followedHashtags.isEmpty }
.sink { [unowned self] _ in
Task {
await self.loadFollowedHashtags()
}
}
.store(in: &cancellables)
}
@discardableResult
@ -120,13 +148,25 @@ class MastodonController: ObservableObject {
})
}
func initialize() async throws {
async let ownAccount = try getOwnAccount()
async let ownInstance = try getOwnInstance()
@MainActor
func initialize() {
// we want this to happen immediately, and synchronously so that the filters (which don't change that often)
// are available when Filterers are constructed
loadCachedFilters()
_ = try await (ownAccount, ownInstance)
Task {
do {
async let ownAccount = try getOwnAccount()
async let ownInstance = try getOwnInstance()
loadLists()
_ = try await (ownAccount, ownInstance)
loadLists()
async let _ = await loadFilters()
} catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
}
}
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
@ -230,7 +270,6 @@ class MastodonController: ObservableObject {
DispatchQueue.main.async {
self.ownInstanceRequest = nil
self.instance = instance
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.success(instance))
@ -248,9 +287,6 @@ class MastodonController: ObservableObject {
case let .success(nodeInfo, _):
DispatchQueue.main.async {
self.nodeInfo = nodeInfo
if let instance = self.instance {
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
}
}
}
@ -279,7 +315,7 @@ class MastodonController: ObservableObject {
run(req) { response in
if case .success(let lists, _) = response {
DispatchQueue.main.async {
self.lists = lists.sorted(using: ListComparator())
self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
}
}
}
@ -289,7 +325,7 @@ class MastodonController: ObservableObject {
func addedList(_ list: List) {
var new = self.lists
new.append(list)
new.sort { $0.title < $1.title }
new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
self.lists = new
}
@ -304,23 +340,55 @@ class MastodonController: ObservableObject {
if let index = new.firstIndex(where: { $0.id == list.id }) {
new[index] = list
}
new.sort(using: ListComparator())
new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
self.lists = new
}
}
@MainActor
private func loadFollowedHashtags() async {
updateFollowedHashtags()
private struct ListComparator: SortComparator {
typealias Compared = List
var underlying = String.Comparator(options: .caseInsensitive)
var order: SortOrder {
get { underlying.order }
set { underlying.order = newValue }
let req = Client.getFollowedHashtags()
if let (hashtags, _) = try? await run(req) {
self.persistentContainer.updateFollowedHashtags(hashtags) {
if case .success(let hashtags) = $0 {
self.followedHashtags = hashtags
}
}
}
}
func compare(_ lhs: List, _ rhs: List) -> ComparisonResult {
return underlying.compare(lhs.title, rhs.title)
@MainActor
func updateFollowedHashtags() {
followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? []
}
@MainActor
func loadFilters() async {
var apiFilters: [AnyFilter]?
if instanceFeatures.filtersV2 {
let req = Client.getFiltersV2()
if let (filters, _) = try? await run(req) {
apiFilters = filters.map { .v2($0) }
}
} else {
let req = Client.getFiltersV1()
if let (filters, _) = try? await run(req) {
apiFilters = filters.map { .v1($0) }
}
}
if let apiFilters {
self.persistentContainer.updateFilters(apiFilters) {
if case .success(let filters) = $0 {
self.filters = filters
}
}
}
}
@MainActor
private func loadCachedFilters() {
filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? []
}
}

View File

@ -0,0 +1,67 @@
//
// ToggleFollowHashtagService.swift
// Tusker
//
// Created by Shadowfacts on 11/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class ToggleFollowHashtagService {
private let hashtag: Hashtag
private let mastodonController: MastodonController
private let presenter: any TuskerNavigationDelegate
init(hashtag: Hashtag, presenter: any TuskerNavigationDelegate) {
self.hashtag = hashtag
self.mastodonController = presenter.apiController
self.presenter = presenter
}
func toggleFollow() async {
let context = mastodonController.persistentContainer.viewContext
var config: ToastConfiguration
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtag.name }) {
do {
let req = Hashtag.unfollow(name: hashtag.name)
_ = try await mastodonController.run(req)
context.delete(existing)
mastodonController.updateFollowedHashtags()
config = ToastConfiguration(title: "Unfollowed Hashtag")
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2
} catch {
config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.toggleFollow()
}
}
} else {
do {
let req = Hashtag.follow(name: hashtag.name)
let (hashtag, _) = try await mastodonController.run(req)
_ = FollowedHashtag(hashtag: hashtag, context: context)
mastodonController.updateFollowedHashtags()
config = ToastConfiguration(title: "Followed Hashtag")
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2
} catch {
config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.toggleFollow()
}
}
}
presenter.showToast(configuration: config, animated: true)
mastodonController.persistentContainer.save(context: context)
}
}

View File

@ -0,0 +1,52 @@
//
// UpdateFilterService.swift
// Tusker
//
// Created by Shadowfacts on 12/3/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
@MainActor
class UpdateFilterService {
private let filter: EditedFilter
private let mastodonController: MastodonController
init(filter: EditedFilter, mastodonController: MastodonController) {
self.filter = filter
self.mastodonController = mastodonController
}
func run() async throws {
let context = mastodonController.persistentContainer.viewContext
let mo = try context.fetch(FilterMO.fetchRequest(id: filter.id!)).first!
let updateFrom: AnyFilter
if mastodonController.instanceFeatures.filtersV2 {
var updates = filter.keywords.map {
if let id = $0.id {
return FilterV2.KeywordUpdate.update(id: id, keyword: $0.keyword, wholeWord: $0.wholeWord)
} else {
return FilterV2.KeywordUpdate.add(keyword: $0.keyword, wholeWord: $0.wholeWord)
}
}
for existing in mo.keywordMOs where !filter.keywords.contains(where: { existing.id == $0.id }) {
if let id = existing.id {
updates.append(.destroy(id: id))
}
}
let req = FilterV2.update(filter.id!, title: filter.title ?? "", context: filter.contexts, expiresIn: filter.expiresIn, action: filter.action, keywords: updates)
let (updated, _) = try await mastodonController.run(req)
updateFrom = .v2(updated)
} else {
let req = FilterV1.update(filter.id!, phrase: filter.keywords.first!.keyword, context: filter.contexts, irreversible: false, wholeWord: filter.keywords.first!.wholeWord, expiresIn: filter.expiresIn)
let (updated, _) = try await mastodonController.run(req)
updateFrom = .v1(updated)
}
mo.updateFrom(apiFilter: updateFrom, context: context)
mastodonController.persistentContainer.save(context: context)
}
}

View File

@ -122,6 +122,10 @@ class DiskCache<T> {
}
}
func getSizeInBytes() -> Int64? {
return fileManager.recursiveSize(url: URL(fileURLWithPath: path, isDirectory: true))
}
}
extension DiskCache {

View File

@ -110,6 +110,10 @@ class ImageCache {
try cache.removeAll()
}
func getDiskSizeInBytes() -> Int64? {
return cache.disk?.getSizeInBytes()
}
typealias Request = URLSessionDataTask
}

View File

@ -11,7 +11,7 @@ import UIKit
class ImageDataCache {
private let memory: MemoryCache<Entry>
private let disk: DiskCache<Data>?
let disk: DiskCache<Data>?
private let storeOriginalDataInMemory: Bool
private let desiredPixelSize: CGSize?

View File

@ -0,0 +1,38 @@
//
// FilterKeywordMO.swift
// Tusker
//
// Created by Shadowfacts on 12/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(FilterKeywordMO)
public final class FilterKeywordMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<FilterKeywordMO> {
return NSFetchRequest(entityName: "FilterKeyword")
}
@NSManaged public var id: String?
@NSManaged public var keyword: String
@NSManaged public var wholeWord: Bool
@NSManaged public var filter: FilterMO
}
extension FilterKeywordMO {
convenience init(apiKeyword keyword: FilterV2.Keyword, context: NSManagedObjectContext) {
self.init(context: context)
self.updateFrom(apiKeyword: keyword)
}
func updateFrom(apiKeyword keyword: FilterV2.Keyword) {
self.id = keyword.id
self.keyword = keyword.keyword
self.wholeWord = keyword.wholeWord
}
}

View File

@ -0,0 +1,140 @@
//
// FilterMO.swift
// Tusker
//
// Created by Shadowfacts on 11/30/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
@objc(FilterMO)
public final class FilterMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<FilterMO> {
return NSFetchRequest(entityName: "Filter")
}
@nonobjc public class func fetchRequest(id: String) -> NSFetchRequest<FilterMO> {
let req = NSFetchRequest<FilterMO>(entityName: "Filter")
req.predicate = NSPredicate(format: "id = %@", id)
return req
}
@NSManaged public var id: String
@NSManaged public var title: String?
@NSManaged private var context: String
@NSManaged public var expiresAt: Date?
@NSManaged public var keywords: NSMutableSet
@NSManaged public var action: String
private var _contexts: [FilterV1.Context]?
public var contexts: [FilterV1.Context] {
get {
if let _contexts {
return _contexts
} else {
_contexts = context.split(separator: ",").compactMap { .init(rawValue: String($0)) }
return _contexts!
}
}
set {
_contexts = newValue
context = newValue.map(\.rawValue).joined(separator: ",")
}
}
var keywordMOs: [FilterKeywordMO] {
keywords.allObjects as! [FilterKeywordMO]
}
var titleOrKeyword: String {
title ?? (keywords.allObjects.first! as! FilterKeywordMO).keyword
}
var filterAction: FilterV2.Action {
get {
FilterV2.Action(rawValue: action)!
}
set {
action = newValue.rawValue
}
}
public override func didChangeValue(forKey key: String) {
super.didChangeValue(forKey: key)
if key == "context" {
_contexts = nil
}
}
}
extension FilterMO {
func updateFrom(apiFilter filter: AnyFilter, context: NSManagedObjectContext) {
switch filter {
case .v1(let v1):
self.updateFrom(apiFilter: v1, context: context)
case .v2(let v2):
self.updateFrom(apiFilter: v2, context: context)
}
}
func updateFrom(apiFilter filter: FilterV1, context: NSManagedObjectContext) {
self.id = filter.id
self.title = nil
self.contexts = filter.contexts
self.expiresAt = filter.expiresAt
self.filterAction = .warn
let keyword: FilterKeywordMO
if self.keywords.count == 0 {
keyword = FilterKeywordMO(context: context)
keyword.filter = self
self.keywords.add(keyword)
} else {
keyword = self.keywords.allObjects.first! as! FilterKeywordMO
}
keyword.keyword = filter.phrase
keyword.wholeWord = filter.wholeWord
keyword.id = nil
}
func updateFrom(apiFilter filter: FilterV2, context: NSManagedObjectContext) {
self.id = filter.id
self.title = filter.title
self.contexts = filter.context
self.expiresAt = filter.expiresAt
self.filterAction = filter.action
var existing = keywordMOs
for keyword in filter.keywords {
if let mo = existing.first(where: { $0.id == keyword.id }) {
mo.updateFrom(apiKeyword: keyword)
existing.removeAll(where: { $0.id == keyword.id })
} else {
let mo = FilterKeywordMO(context: context)
mo.updateFrom(apiKeyword: keyword)
mo.filter = self
self.keywords.add(mo)
}
}
for unupdated in existing {
context.delete(unupdated)
}
}
}
enum AnyFilter {
case v1(FilterV1)
case v2(FilterV2)
var id: String {
switch self {
case .v1(let v1):
return v1.id
case .v2(let v2):
return v2.id
}
}
}

View File

@ -0,0 +1,38 @@
//
// FollowedHashtag.swift
// Tusker
//
// Created by Shadowfacts on 11/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import WebURLFoundationExtras
@objc(FollowedHashtag)
public final class FollowedHashtag: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<FollowedHashtag> {
return NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
}
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<FollowedHashtag> {
let req = NSFetchRequest<FollowedHashtag>(entityName: "FollowedHashtag")
req.predicate = NSPredicate(format: "name LIKE[cd] %@", name)
return req
}
@NSManaged public var name: String
@NSManaged public var url: URL
}
extension FollowedHashtag {
convenience init(hashtag: Hashtag, context: NSManagedObjectContext) {
self.init(context: context)
self.name = hashtag.name
self.url = URL(hashtag.url)!
}
}

View File

@ -53,7 +53,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
storeDescription.type = NSInMemoryStoreType
persistentStoreDescriptions = [storeDescription]
} else {
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
}
loadPersistentStores { (description, error) in
@ -267,6 +267,66 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
}
func updateFollowedHashtags(_ hashtags: [Hashtag], completion: @escaping (Result<[FollowedHashtag], Error>) -> Void) {
viewContext.perform {
do {
var all = try self.viewContext.fetch(FollowedHashtag.fetchRequest())
let toDelete = all.filter { existing in !hashtags.contains(where: { $0.name == existing.name }) }.map(\.objectID)
if !toDelete.isEmpty {
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
}
for hashtag in hashtags where !all.contains(where: { $0.name == hashtag.name}) {
let mo = FollowedHashtag(hashtag: hashtag, context: self.viewContext)
all.append(mo)
}
self.save(context: self.viewContext)
completion(.success(all))
} catch {
completion(.failure(error))
}
}
}
func hasFollowedHashtag(_ hashtag: Hashtag) -> Bool {
do {
let req = FollowedHashtag.fetchRequest(name: name)
return try viewContext.count(for: req) > 0
} catch {
return false
}
}
func updateFilters(_ filters: [AnyFilter], completion: @escaping (Result<[FilterMO], Error>) -> Void) {
viewContext.perform {
do {
var all = try self.viewContext.fetch(FilterMO.fetchRequest())
let toDelete = all.filter { existing in !filters.contains(where: { $0.id == existing.id }) }.map(\.objectID)
if !toDelete.isEmpty {
try self.viewContext.execute(NSBatchDeleteRequest(objectIDs: toDelete))
}
for filter in filters {
if let existing = all.first(where: { $0.id == filter.id }) {
existing.updateFrom(apiFilter: filter, context: self.viewContext)
} else {
let mo = FilterMO(context: self.viewContext)
mo.updateFrom(apiFilter: filter, context: self.viewContext)
all.append(mo)
}
}
self.save(context: self.viewContext)
completion(.success(all))
} catch {
completion(.failure(error))
}
}
}
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags {

View File

@ -20,7 +20,7 @@ public final class SavedHashtag: NSManagedObject {
@nonobjc public class func fetchRequest(name: String) -> NSFetchRequest<SavedHashtag> {
let req = NSFetchRequest<SavedHashtag>(entityName: "SavedHashtag")
req.predicate = NSPredicate(format: "name = %@", name)
req.predicate = NSPredicate(format: "name LIKE[cd] %@", name)
return req
}

View File

@ -28,6 +28,24 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES">
<attribute name="action" attributeType="String" defaultValueString="warn"/>
<attribute name="context" attributeType="String"/>
<attribute name="expiresAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="id" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/>
<relationship name="keywords" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="FilterKeyword" inverseName="filter" inverseEntity="FilterKeyword"/>
</entity>
<entity name="FilterKeyword" representedClassName="FilterKeywordMO" syncable="YES">
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="keyword" attributeType="String"/>
<attribute name="wholeWord" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="filter" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Filter" inverseName="keywords" inverseEntity="Filter"/>
</entity>
<entity name="FollowedHashtag" representedClassName="FollowedHashtag" syncable="YES">
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
</entity>
<entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>

View File

@ -0,0 +1,35 @@
//
// FileManager+Size.swift
// Tusker
//
// Created by Shadowfacts on 11/28/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
extension FileManager {
func recursiveSize(url: URL) -> Int64? {
if (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true {
return size(url: url)
} else {
guard let enumerator = enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]) else {
return nil
}
var total: Int64 = 0
for case let url as URL in enumerator {
total += size(url: url) ?? 0
}
return total
}
}
}
private func size(url: URL) -> Int64? {
guard let resourceValues = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .totalFileAllocatedSizeKey]),
resourceValues.isRegularFile ?? false,
let size = resourceValues.fileSize ?? resourceValues.totalFileAllocatedSize else {
return nil
}
return Int64(size)
}

View File

@ -0,0 +1,37 @@
//
// Filter+Helpers.swift
// Tusker
//
// Created by Shadowfacts on 12/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Pachyderm
extension FilterV1.Context {
var displayName: String {
switch self {
case .home:
return "Home and lists"
case .notifications:
return "Notifications"
case .public:
return "Public timelines"
case .thread:
return "Conversations"
case .account:
return "Profiles"
}
}
}
extension FilterV2.Action {
var displayName: String {
switch self {
case .warn:
return "Warn"
case .hide:
return "Hide"
}
}
}

View File

@ -9,12 +9,12 @@
import Foundation
import Pachyderm
extension StatusState {
extension CollapseState {
func resolveFor(status: StatusMO, height: CGFloat) {
func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
let longEnoughToCollapse: Bool
if Preferences.shared.collapseLongPosts,
height > 500 {
height > 500 || (textLength != nil && textLength! > 500) {
longEnoughToCollapse = true
} else {
longEnoughToCollapse = false

208
Tusker/Filterer.swift Normal file
View File

@ -0,0 +1,208 @@
//
// Filterer.swift
// Tusker
//
// Created by Shadowfacts on 12/3/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import Combine
/// An opaque object that serves as the cache for the filtered-ness of a particular status.
class FilterState {
static var unknown: FilterState { FilterState(state: .unknown) }
fileprivate var state: State
var isWarning: Bool {
switch state {
case .known(.warn(_), _):
return true
default:
return false
}
}
private init(state: State) {
self.state = state
}
fileprivate enum State {
case unknown
case known(Filterer.Result, generation: Int)
}
}
@MainActor
class Filterer {
let mastodonController: MastodonController
let context: FilterV1.Context
var filtersChanged: ((Bool) -> Void)?
var htmlConverter = HTMLConverter()
private var hasSetup = false
private var matchers = [(NSRegularExpression, Result)]()
private var allFiltersObserver: AnyCancellable?
private var filterObservers = Set<AnyCancellable>()
// the generation is incremented when the matchers change, to indicate that older cached FilterStates
// are no longer valid, without needing to go through and update each of them
private var generation = 0
init(mastodonController: MastodonController, context: FilterV1.Context) {
self.mastodonController = mastodonController
self.context = context
allFiltersObserver = mastodonController.$filters
.sink { [unowned self] in
if self.hasSetup {
self.setupFilters(filters: $0)
}
}
}
private func setupFilters(filters: [FilterMO]) {
let oldMatchers = matchers
matchers = []
filterObservers = []
for filter in filters where (filter.expiresAt == nil || filter.expiresAt! > Date()) && filter.contexts.contains(context) {
guard let matcher = filter.createMatcher() else {
continue
}
matchers.append(matcher)
filter.objectWillChange
.sink { [unowned self] _ in
// wait until after the change happens
DispatchQueue.main.async {
self.setupFilters(filters: self.mastodonController.filters)
}
}
.store(in: &filterObservers)
}
if hasSetup {
var allMatch: Bool = false
var actionsChanged: Bool = false
if matchers.count != oldMatchers.count {
allMatch = false
actionsChanged = true
} else {
for (old, new) in zip(oldMatchers, matchers) {
if old.1 != new.1 {
allMatch = false
actionsChanged = true
break
} else if old.0.pattern != new.0.pattern {
allMatch = false
// continue because we want to know if any actions changed
continue
}
}
}
if !allMatch {
generation += 1
filtersChanged?(actionsChanged)
}
} else {
hasSetup = true
}
}
// Use a closure for the status in case the result is cached and we don't need to look it up
func resolve(state: FilterState, status: () -> StatusMO) -> (Filterer.Result, NSAttributedString?) {
switch state.state {
case .known(_, generation: let knownGen) where knownGen < generation:
fallthrough
case .unknown:
let (result, attributedString) = doResolve(status: status())
state.state = .known(result, generation: generation)
return (result, attributedString)
case .known(let result, _):
return (result, nil)
}
}
func setResult(_ result: Result, for state: FilterState) {
state.state = .known(result, generation: generation)
}
func isKnownHide(state: FilterState) -> Bool {
switch state.state {
case .known(.hide, generation: let gen) where gen >= generation:
return true
default:
return false
}
}
private func doResolve(status: StatusMO) -> (Result, NSAttributedString?) {
if !hasSetup {
setupFilters(filters: mastodonController.filters)
}
if matchers.isEmpty {
return (.allow, nil)
}
@Lazy var text = self.htmlConverter.convert(status.content)
for (regex, result) in matchers {
if (!status.spoilerText.isEmpty && regex.numberOfMatches(in: status.spoilerText, range: NSRange(location: 0, length: status.spoilerText.utf16.count)) > 0)
|| regex.numberOfMatches(in: text.string, range: NSRange(location: 0, length: text.length)) > 0 {
return (result, _text.valueIfInitialized)
}
}
return (.allow, _text.valueIfInitialized)
}
enum Result: Equatable {
case allow
case hide
case warn(String)
}
}
private extension FilterMO {
func createMatcher() -> (NSRegularExpression, Filterer.Result)? {
guard keywords.count > 0 else {
return nil
}
// TODO: it would be cool to use the Regex builder stuff for this, but it's iOS 16 only
var pattern = ""
var isFirst = true
for keyword in keywordMOs {
if isFirst {
isFirst = false
} else {
pattern += "|"
}
pattern += "("
if keyword.wholeWord {
pattern += "\\b"
}
pattern += NSRegularExpression.escapedPattern(for: keyword.keyword)
if keyword.wholeWord {
pattern += "\\b"
}
pattern += ")"
}
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.useUnicodeWordBoundaries, .caseInsensitive]) else {
return nil
}
let result: Filterer.Result
switch filterAction {
case .hide:
result = .hide
case .warn:
result = .warn(titleOrKeyword)
}
return (regex, result)
}
}

128
Tusker/HTMLConverter.swift Normal file
View File

@ -0,0 +1,128 @@
//
// HTMLConverter.swift
// Tusker
//
// Created by Shadowfacts on 12/3/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import SwiftSoup
import WebURL
import WebURLFoundationExtras
struct HTMLConverter {
static let defaultFont = UIFont.systemFont(ofSize: 17)
static let defaultColor = UIColor.label
static let defaultParagraphStyle: NSParagraphStyle = {
let style = NSMutableParagraphStyle()
// 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis
style.lineSpacing = 2
return style
}()
var font: UIFont = defaultFont
var color: UIColor = defaultColor
var paragraphStyle: NSParagraphStyle = defaultParagraphStyle
func convert(_ html: String) -> NSAttributedString {
let doc = try! SwiftSoup.parseBodyFragment(html)
let body = doc.body()!
let attributedText = attributedTextForHTMLNode(body)
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace()
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
return mutAttrString
}
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString {
switch node {
case let node as TextNode:
let text: String
if usePreformattedText {
text = node.getWholeText()
} else {
text = node.text()
}
return NSAttributedString(string: text, attributes: [.font: font, .foregroundColor: color])
case let node as Element:
let attributed = NSMutableAttributedString(string: "", attributes: [.font: font, .foregroundColor: color])
for child in node.getChildNodes() {
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
}
switch node.tagName() {
case "br":
// need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which
// screws up its determination of the line height making multiple lines of emojis squash together
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
case "a":
let href = try! node.attr("href")
if let webURL = WebURL(href),
let url = URL(webURL) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
} else if let url = URL(string: href) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
}
case "p":
attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: font]))
case "em", "i":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = font
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
case "strong", "b":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = font
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? font
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
case "code":
// TODO: this probably breaks with dynamic type
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .regular), range: attributed.fullRange)
case "pre":
attributed.append(NSAttributedString(string: "\n\n"))
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .regular), 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: UIFont.monospacedDigitSystemFont(ofSize: font.pointSize, weight: .regular)])
} else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: font])
} else {
bullet = NSAttributedString()
}
attributed.insert(bullet, at: 0)
attributed.append(NSAttributedString(string: "\n", attributes: [.font: font]))
default:
break
}
return attributed
default:
fatalError("Unexpected node type \(type(of: node))")
}
}
}

54
Tusker/Lazy.swift Normal file
View File

@ -0,0 +1,54 @@
//
// Lazy.swift
// Tusker
//
// Created by Shadowfacts on 12/4/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
/// A lazy initialization property wrapper that allows checking the initialization state.
@propertyWrapper
enum Lazy<Value> {
case uninitialized(() -> Value)
case initialized(Value)
init(wrappedValue: @autoclosure @escaping () -> Value) {
self = .uninitialized(wrappedValue)
}
/// Returns the contained value, initializing it if the value hasn't been accessed before.
var wrappedValue: Value {
mutating get {
switch self {
case .uninitialized(let closure):
let value = closure()
self = .initialized(value)
return value
case .initialized(let value):
return value
}
}
}
/// Whether this Lazy has been initialized yet.
var isInitialized: Bool {
switch self {
case .uninitialized(_):
return false
case .initialized(_):
return true
}
}
/// If this Lazy is initialized, this returns the value. Otherwise, it returns `nil`.
var valueIfInitialized: Value? {
switch self {
case .uninitialized(_):
return nil
case .initialized(let value):
return value
}
}
}

View File

@ -208,6 +208,12 @@ extension LocalData {
self.accessToken = accessToken
}
/// A filename-safe string for this account
var persistenceKey: String {
// slashes are not allowed in the persistent store coordinator name
id.replacingOccurrences(of: "/", with: "_")
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}

View File

@ -13,6 +13,7 @@ struct Logging {
private init() {}
static let general = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "General")
static let generalSignposter = OSSignposter(logger: general)
static func getLogData() -> Data? {
do {

View File

@ -177,6 +177,7 @@ extension MastodonController {
var acctsToMention = [String]()
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
var localOnly = false
var contentWarning = ""
if let inReplyToID = inReplyToID,
@ -184,6 +185,7 @@ extension MastodonController {
acctsToMention.append(inReplyTo.account.acct)
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
visibility = min(visibility, inReplyTo.visibility)
localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly
if !inReplyTo.spoilerText.isEmpty {
switch Preferences.shared.contentWarningCopyMode {
@ -213,6 +215,7 @@ extension MastodonController {
draft.text = acctsToMention.map { "@\($0) " }.joined()
draft.initialText = draft.text
draft.visibility = visibility
draft.localOnly = localOnly
draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty

View File

@ -0,0 +1,69 @@
//
// EditedFilter.swift
// Tusker
//
// Created by Shadowfacts on 12/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class EditedFilter: ObservableObject {
let id: String?
@Published var title: String?
@Published var contexts: [FilterV1.Context]
@Published var expiresIn: TimeInterval?
@Published var keywords: [Keyword]
@Published var action: FilterV2.Action
init() {
self.id = nil
self.title = nil
self.contexts = [.home]
self.expiresIn = nil
self.keywords = [.init(id: nil, keyword: "", wholeWord: true)]
self.action = .warn
}
init(_ mo: FilterMO) {
self.id = mo.id
self.title = mo.title
self.contexts = mo.contexts
if let expiresAt = mo.expiresAt {
expiresIn = expiresAt.timeIntervalSinceNow
}
self.keywords = mo.keywordMOs.map {
Keyword(id: $0.id, keyword: $0.keyword, wholeWord: $0.wholeWord)
}
self.action = mo.filterAction
}
init(copying other: EditedFilter) {
self.id = other.id
self.title = other.title
self.contexts = other.contexts
self.expiresIn = other.expiresIn
self.keywords = other.keywords
self.action = other.action
}
func isValid(for mastodonController: MastodonController) -> Bool {
if mastodonController.instanceFeatures.filtersV2 && (title == nil || title!.isEmpty) {
return false
}
if keywords.isEmpty || keywords.contains(where: { $0.keyword.isEmpty }) {
return false
}
if contexts.isEmpty {
return false
}
return true
}
struct Keyword {
let id: String?
var keyword: String
var wholeWord: Bool
}
}

View File

@ -125,6 +125,8 @@ class Preferences: Codable, ObservableObject {
@Published var showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false
@Published var hideActionsInTimeline = false
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
// MARK: Composing
@Published var defaultPostVisibility = Status.Visibility.public

View File

@ -0,0 +1,172 @@
//
// StatusSwipeAction.swift
// Tusker
//
// Created by Shadowfacts on 11/26/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
case reply
case favorite
case reblog
case share
case bookmark
case openInSafari
var displayName: String {
switch self {
case .reply:
return "Reply"
case .favorite:
return "Favorite"
case .reblog:
return "Reblog"
case .share:
return "Share"
case .bookmark:
return "Bookmark"
case .openInSafari:
return "Open in Safari"
}
}
var systemImageName: String {
switch self {
case .reply:
return "arrowshape.turn.up.left.fill"
case .favorite:
return "star.fill"
case .reblog:
return "repeat"
case .share:
return "square.and.arrow.up"
case .bookmark:
return "bookmark.fill"
case .openInSafari:
return "safari"
}
}
func createAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
switch self {
case .reply:
return createReplyAction(status: status, container: container)
case .favorite:
return createFavoriteAction(status: status, container: container)
case .reblog:
return createReblogAction(status: status, container: container)
case .share:
return createShareAction(status: status, container: container)
case .bookmark:
return createBookmarkAction(status: status, container: container)
case .openInSafari:
return createOpenInSafariAction(status: status, container: container)
}
}
}
protocol StatusSwipeActionContainer: UIView {
var mastodonController: MastodonController! { get }
var navigationDelegate: any TuskerNavigationDelegate { get }
var toastableViewController: ToastableViewController? { get }
// necessary b/c the reblog-handling logic only exists in the cells
func performReplyAction()
}
private func createReplyAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else {
return nil
}
let action = UIContextualAction(style: .normal, title: "Reply") { [unowned container] _, _, completion in
container.performReplyAction()
completion(true)
}
action.image = UIImage(systemName: "arrowshape.turn.up.left.fill")
action.backgroundColor = container.tintColor
return action
}
private func createFavoriteAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else {
return nil
}
let title = status.favourited ? "Unfavorite" : "Favorite"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
Task { @MainActor in
await FavoriteService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleFavorite()
completion(true)
}
}
action.image = UIImage(systemName: "star.fill")
action.backgroundColor = status.favourited ? UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) : UIColor(displayP3Red: 1, green: 204/255, blue: 0, alpha: 1)
return action
}
private func createReblogAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else {
return nil
}
let title = status.reblogged ? "Unreblog" : "Reblog"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
Task { @MainActor in
await ReblogService(status: status, mastodonController: container.mastodonController, presenter: container.navigationDelegate).toggleReblog()
completion(true)
}
}
action.image = UIImage(systemName: "repeat")
action.backgroundColor = status.reblogged ? UIColor(displayP3Red: 235/255, green: 77/255, blue: 62/255, alpha: 1) : container.tintColor
return action
}
private func createShareAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Share") { [unowned container] _, _, completion in
container.navigationDelegate.showMoreOptions(forStatus: status.id, source: .view(container))
completion(true)
}
// bold to more closesly match other action symbols
let config = UIImage.SymbolConfiguration(weight: .bold)
action.image = UIImage(systemName: "square.and.arrow.up")!.applyingSymbolConfiguration(config)!
action.backgroundColor = .lightGray
return action
}
private func createBookmarkAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction? {
guard container.mastodonController.loggedIn else {
return nil
}
let bookmarked = status.bookmarked ?? false
let title = bookmarked ? "Unbookmark" : "Bookmark"
let action = UIContextualAction(style: .normal, title: title) { [unowned container] _, _, completion in
Task { @MainActor in
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
do {
let (status, _) = try await container.mastodonController.run(request)
container.mastodonController.persistentContainer.addOrUpdate(status: status)
} catch {
if let toastable = container.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil)
toastable.showToast(configuration: config, animated: true)
}
}
completion(true)
}
}
action.image = UIImage(systemName: "bookmark.fill")
action.backgroundColor = .systemRed
return action
}
private func createOpenInSafariAction(status: StatusMO, container: StatusSwipeActionContainer) -> UIContextualAction {
let action = UIContextualAction(style: .normal, title: "Open in Safari") { [unowned container] _, _, completion in
container.navigationDelegate.selected(url: status.url!, allowUniversalLinks: false)
completion(true)
}
action.image = UIImage(systemName: "safari")
action.backgroundColor = container.tintColor
return action
}

View File

@ -42,9 +42,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate {
let controller = MastodonController.getForAccount(account)
session.mastodonController = controller
Task {
try? await controller.initialize()
}
controller.initialize()
guard let rootVC = viewController(for: activity, mastodonController: controller) else {
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)

View File

@ -50,9 +50,7 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate {
}
session.mastodonController = controller
Task {
try? await controller.initialize()
}
controller.initialize()
let composeVC = ComposeHostingController(draft: draft, mastodonController: controller)
composeVC.delegate = self

View File

@ -212,9 +212,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func createAppUI() -> TuskerRootViewController {
let mastodonController = window!.windowScene!.session.mastodonController!
Task {
try? await mastodonController.initialize()
}
mastodonController.initialize()
let split = MainSplitViewController(mastodonController: mastodonController)
if UIDevice.current.userInterfaceIdiom == .phone,

View File

@ -58,6 +58,13 @@ class AccountListViewController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
}
}
extension AccountListViewController {
@ -81,7 +88,7 @@ extension AccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
}
}

View File

@ -99,11 +99,9 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
return vc
case .gifv:
// Passing the source view to the LargeImageGifvContentView is a crappy workaround for not
// having the video size directly inside the content view. This will break when there
// having the current frame to use as the animationImage. This will break when there
// are more than 4 attachments and there is a gifv at index >= 3 (the More... button will show
// in place of the fourth attachment, so there aren't source views for the attachments at index >= 3).
// Really, what should happen is the LargeImageGifvContentView should get the size of the video from
// the AVFoundation instead of the source view.
// This isn't a priority as only Mastodon converts gifs to gifvs, and Mastodon (in its default configuration,
// I don't know about forks) doesn't allow more than four attachments, meaning there will always be a source view.
let gifvContentView = LargeImageGifvContentView(attachment: attachment, source: sourceViews[index]!)

View File

@ -17,7 +17,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
private var loaded = false
var statuses: [(id: String, state: StatusState)] = []
var statuses: [(id: String, state: CollapseState)] = []
var newer: RequestRange?
var older: RequestRange?

View File

@ -176,6 +176,19 @@ struct ComposeAutocompleteEmojisView: View {
@State private var emojis: [Emoji] = []
@ScaledMetric private var emojiSize = 30
private var emojisBySection: [String: [Emoji]] {
var values: [String: [Emoji]] = [:]
for emoji in emojis {
let key = emoji.category ?? ""
if !values.keys.contains(key) {
values[key] = [emoji]
} else {
values[key]!.append(emoji)
}
}
return values
}
var body: some View {
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
HStack(alignment: expanded ? .top : .center, spacing: 0) {
@ -214,12 +227,28 @@ struct ComposeAutocompleteEmojisView: View {
private var verticalGrid: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
ForEach(emojis, id: \.shortcode) { (emoji) in
Button {
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
} label: {
CustomEmojiImageView(emoji: emoji)
.frame(height: emojiSize)
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
Section {
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
Button {
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
} label: {
CustomEmojiImageView(emoji: emoji)
.frame(height: emojiSize)
}
}
} header: {
if !section.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text(section)
.font(.caption)
Rectangle()
.foregroundColor(Color(.separator))
.frame(height: 0.5)
}
.padding(.top, 4)
}
}
}
}

View File

@ -22,7 +22,7 @@ struct ComposeCurrentAccount: View {
ComposeAvatarImageView(url: account?.avatar)
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.accessibility(label: Text(account != nil ? "\(account!.displayName) avatar" : "Avatar"))
.accessibilityHidden(true)
if let id = account?.id,
let account = mastodonController.persistentContainer.account(for: id) {

View File

@ -43,11 +43,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
super.init(rootView: wrapper)
self.uiState.delegate = self
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id)
updateNavigationTitle(draft: uiState.draft)
self.uiState.$draft
.flatMap(\.objectWillChange)
.debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility))
@ -55,12 +55,27 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
DraftsManager.save()
}
.store(in: &cancellables)
self.uiState.$draft
.sink { [unowned self] draft in
self.updateNavigationTitle(draft: draft)
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func updateNavigationTitle(draft: Draft) {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
navigationItem.title = "Reply to @\(status.account.acct)"
} else {
navigationItem.title = "New Post"
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
@ -92,6 +107,11 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Wra
}
}
override func accessibilityPerformEscape() -> Bool {
dismissCompose(mode: .cancel)
return true
}
// MARK: Duckable
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {

View File

@ -80,6 +80,7 @@ struct ComposeReplyView: View {
.frame(width: 50, height: 50)
.cornerRadius(preferences.avatarStyle.cornerRadiusFraction * 50)
.offset(x: 0, y: offset)
.accessibilityHidden(true)
}
}

View File

@ -107,7 +107,6 @@ struct ComposeView: View {
globalFrameOutsideList = frame
}
})
.navigationTitle(navTitle)
.sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController)
}
@ -203,23 +202,19 @@ struct ComposeView: View {
private var header: some View {
HStack(alignment: .top) {
ComposeCurrentAccount()
.accessibilitySortPriority(1)
Spacer()
Text(verbatim: charactersRemaining.description)
.foregroundColor(charactersRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit())
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining"))
// this should come first, so VO users can back to it from the main compose text view
.accessibilitySortPriority(0)
}.frame(height: 50)
}
private var navTitle: Text {
if let id = draft.inReplyToID,
let status = mastodonController.persistentContainer.status(for: id) {
return Text("Reply to @\(status.account.acct)")
} else {
return Text("New Post")
}
}
private var cancelButton: some View {
Button(action: self.cancel) {
Text("Cancel")
@ -236,6 +231,7 @@ struct ComposeView: View {
} label: {
Text("Post")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!postButtonEnabled)
}

View File

@ -30,7 +30,7 @@ class ConversationTableViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
let mainStatusID: String
let mainStatusState: StatusState
let mainStatusState: CollapseState
var statusIDToScrollToOnLoad: String
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
@ -39,7 +39,7 @@ class ConversationTableViewController: EnhancedTableViewController {
private var loadingState = LoadingState.unloaded
init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) {
init(for mainStatusID: String, state: CollapseState = .unknown, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID
self.mainStatusState = state
self.statusIDToScrollToOnLoad = mainStatusID
@ -336,7 +336,7 @@ class ConversationTableViewController: EnhancedTableViewController {
}
}
func item(for indexPath: IndexPath) -> (id: String, state: StatusState)? {
func item(for indexPath: IndexPath) -> (id: String, state: CollapseState)? {
return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in
switch item {
case let .status(id: id, state: state):
@ -402,7 +402,7 @@ extension ConversationTableViewController {
case childThread(firstStatusID: String)
}
enum Item: Hashable {
case status(id: String, state: StatusState)
case status(id: String, state: CollapseState)
case expandThread(childThreads: [ConversationNode], inline: Bool)
static func == (lhs: Item, rhs: Item) -> Bool {
@ -443,7 +443,7 @@ extension ConversationTableViewController {
extension ConversationTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
// transfer show statuses automatically state when showing new conversation
vc.showStatusesAutomatically = self.showStatusesAutomatically

View File

@ -174,7 +174,7 @@ extension ProfileDirectoryViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) {
return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
} actionProvider: { (_) in
let actions = self.actionsForProfile(accountID: account.id, sourceView: self.collectionView.cellForItem(at: indexPath))
let actions = self.actionsForProfile(accountID: account.id, source: .view(self.collectionView.cellForItem(at: indexPath)))
return UIMenu(children: actions)
}

View File

@ -98,7 +98,7 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath))))
}
}
}

View File

@ -62,9 +62,10 @@ class TrendingStatusesViewController: UIViewController {
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.updateUI(statusID: item.0, state: item.1)
// TODO: filter these
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
cell.indicator.startAnimating()
@ -119,7 +120,7 @@ extension TrendingStatusesViewController {
case statuses
}
enum Item: Hashable {
case status(id: String, state: StatusState)
case status(id: String, state: CollapseState)
case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> Bool {
@ -206,6 +207,10 @@ extension TrendingStatusesViewController: StatusCollectionViewCellDelegate {
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
fatalError()
}
}
extension TrendingStatusesViewController: StatusBarTappableViewController {

View File

@ -0,0 +1,214 @@
//
// EditFilterView.swift
// Tusker
//
// Created by Shadowfacts on 12/2/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct EditFilterView: View {
private static let expiresInOptions: [MenuPicker<TimeInterval>.Option] = {
let f = DateComponentsFormatter()
f.maximumUnitCount = 1
f.unitsStyle = .full
f.allowedUnits = [.weekOfMonth, .day, .hour, .minute]
let durations: [TimeInterval] = [
30 * 60,
60 * 60,
6 * 60 * 60,
24 * 60 * 60,
3 * 24 * 60 * 60,
7 * 24 * 60 * 60,
]
return durations.map { .init(value: $0, title: f.string(from: $0)!) }
}()
@ObservedObject var filter: EditedFilter
let create: Bool
@EnvironmentObject private var mastodonController: MastodonController
@Environment(\.dismiss) private var dismiss
@State private var originalFilter: EditedFilter
@State private var edited = false
@State private var isSaving = false
@State private var saveError: (any Error)?
init(filter: EditedFilter, create: Bool) {
self.filter = filter
self.create = create
self._originalFilter = State(wrappedValue: EditedFilter(copying: filter))
if let expiresIn = filter.expiresIn {
self._expiresIn = State(wrappedValue: Self.expiresInOptions.min(by: { a, b in
let aDist = abs(a.value - expiresIn)
let bDist = abs(b.value - expiresIn)
return aDist < bDist
})!.value)
} else {
self._expiresIn = State(wrappedValue: 24 * 60 * 60)
}
}
@State private var expiresIn: TimeInterval {
didSet {
if expires.wrappedValue {
filter.expiresIn = expiresIn
}
}
}
private var expires: Binding<Bool> {
Binding {
filter.expiresIn != nil
} set: { newValue in
filter.expiresIn = newValue ? expiresIn : nil
}
}
var body: some View {
Form {
if mastodonController.instanceFeatures.filtersV2 {
Section {
TextField("Title", text: Binding(get: {
filter.title ?? ""
}, set: { newValue in
filter.title = newValue
}))
}
}
Section {
ForEach(Array($filter.keywords.enumerated()), id: \.offset) { keyword in
VStack {
TextField("Phrase", text: keyword.element.keyword)
Toggle("Whole Word", isOn: keyword.element.wholeWord)
}
}
.onDelete(perform: mastodonController.instanceFeatures.filtersV2 ? { indices in
filter.keywords.remove(atOffsets: indices)
} : nil)
if mastodonController.instanceFeatures.filtersV2 {
Button {
let new = EditedFilter.Keyword(id: nil, keyword: "", wholeWord: true)
withAnimation {
filter.keywords.append(new)
}
} label: {
Label("Add Keyword", systemImage: "plus")
}
}
}
Section {
if mastodonController.instanceFeatures.filtersV2 {
Picker(selection: $filter.action) {
ForEach(FilterV2.Action.allCases, id: \.self) { action in
Text(action.displayName).tag(action)
}
} label: {
Text("Action")
}
}
Toggle("Expires", isOn: expires)
if expires.wrappedValue {
Picker(selection: $expiresIn) {
ForEach(Self.expiresInOptions, id: \.value) { option in
Text(option.title).tag(option.value)
}
} label: {
Text("Duration")
}
}
}
Section {
ForEach(FilterV1.Context.allCases, id: \.rawValue) { context in
Toggle(isOn: Binding(get: {
filter.contexts.contains(context)
}, set: { newValue in
if newValue {
if !filter.contexts.contains(context) {
filter.contexts.append(context)
}
} else if filter.contexts.count > 1 {
filter.contexts.removeAll(where: { $0 == context })
}
})) {
Text(context.displayName)
}
.toggleStyle(FilterContextToggleStyle())
}
} header: {
Text("Contexts")
}
}
.navigationTitle("Edit Filter")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView()
.progressViewStyle(.circular)
} else {
Button(create ? "Create" : "Save") {
saveFilter()
}
.disabled(!filter.isValid(for: mastodonController) || !edited)
}
}
}
.alertWithData("Error Saving Filter", data: $saveError, actions: { _ in
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})
.onReceive(filter.objectWillChange, perform: { _ in
edited = true
})
}
private func saveFilter() {
Task {
do {
isSaving = true
if create {
try await CreateFilterService(filter: filter, mastodonController: mastodonController).run()
} else {
try await UpdateFilterService(filter: filter, mastodonController: mastodonController).run()
}
dismiss()
} catch {
isSaving = false
saveError = error
}
}
}
}
private struct FilterContextToggleStyle: ToggleStyle {
func makeBody(configuration: Configuration) -> some View {
Button {
configuration.isOn.toggle()
} label: {
HStack {
configuration.label
.foregroundColor(.primary)
Spacer()
if configuration.isOn {
Image(systemName: "checkmark")
}
}
}
}
}
//struct EditFilterView_Previews: PreviewProvider {
// static var previews: some View {
// EditFilterView()
// }
//}

View File

@ -0,0 +1,63 @@
//
// FilterRow.swift
// Tusker
//
// Created by Shadowfacts on 11/30/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct FilterRow: View {
@ObservedObject var filter: FilterMO
@EnvironmentObject var mastodonController: MastodonController
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .top) {
Text(mastodonController.instanceFeatures.filtersV2 ? filter.title ?? "" : filter.keywordMOs.first?.keyword ?? "")
.font(.headline)
Spacer()
if let expiresAt = filter.expiresAt {
if expiresAt <= Date() {
Text("Expired")
.font(.body.lowercaseSmallCaps())
.foregroundColor(.red)
} else {
Text(expiresAt.formatted(.relative(presentation: .numeric, unitsStyle: .narrow)))
.font(.body.lowercaseSmallCaps())
}
}
}
if mastodonController.instanceFeatures.filtersV2 {
Text("^[\(filter.keywords.count) keywords](inflect: true)")
}
// rather than mapping over filter.contexts, because we want a consistent order
Text(FilterV1.Context.allCases.filter { filter.contexts.contains($0) }.map(\.displayName).formatted())
.font(.subheadline)
if !mastodonController.instanceFeatures.filtersV2 && filter.keywordMOs.first?.wholeWord == true {
Text("Whole word")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
}
}
struct FilterRow_Previews: PreviewProvider {
static var previews: some View {
let filter = FilterMO()
filter.id = "1"
// filter.phrase = "test"
filter.expiresAt = Date().addingTimeInterval(60 * 60)
// filter.wholeWord = true
filter.contexts = [.home]
return FilterRow(filter: filter)
}
}

View File

@ -0,0 +1,129 @@
//
// FiltersView.swift
// Tusker
//
// Created by Shadowfacts on 11/30/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct FiltersView: View {
let mastodonController: MastodonController
var body: some View {
FiltersList()
.environmentObject(mastodonController)
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
}
}
struct FiltersList: View {
@EnvironmentObject private var mastodonController: MastodonController
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
@Environment(\.dismiss) private var dismiss
@State private var deletionError: (any Error)?
var body: some View {
if #available(iOS 16.0, *) {
NavigationStack {
navigationBody
}
} else {
NavigationView {
navigationBody
}
.navigationViewStyle(.stack)
}
}
private var unexpiredFilters: [FilterMO] {
filters.filter { $0.expiresAt == nil || $0.expiresAt! > Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.titleOrKeyword))
}
private var expiredFilters: [FilterMO] {
filters.filter { $0.expiresAt != nil && $0.expiresAt! <= Date() }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.titleOrKeyword))
}
private var navigationBody: some View {
List {
Section {
NavigationLink {
EditFilterView(filter: EditedFilter(), create: true)
} label: {
Label("Add Filter", systemImage: "plus")
.foregroundColor(.accentColor)
}
}
filtersSection(unexpiredFilters, header: Text("Active"))
filtersSection(expiredFilters, header: Text("Expired"))
}
.navigationTitle(Text("Filters"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.alertWithData("Error Deleting Filter", data: $deletionError, actions: { _ in
Button("OK") {
self.deletionError = nil
}
}, message: { error in
Text(error.localizedDescription)
})
.task {
await mastodonController.loadFilters()
}
}
@ViewBuilder
private func filtersSection(_ filters: [FilterMO], header: some View) -> some View {
if !filters.isEmpty {
Section {
ForEach(filters, id: \.id) { filter in
NavigationLink {
EditFilterView(filter: EditedFilter(filter), create: false)
} label: {
FilterRow(filter: filter)
}
.contextMenu {
Button(role: .destructive) {
deleteFilter(filter)
} label: {
Label("Delete Filter", systemImage: "trash")
}
}
}
.onDelete { indices in
for filter in indices.map({ filters[$0] }) {
deleteFilter(filter)
}
}
} header: {
header
}
}
}
private func deleteFilter(_ filter: FilterMO) {
Task {
do {
try await DeleteFilterService(filter: filter, mastodonController: mastodonController).run()
} catch {
self.deletionError = error
}
}
}
}
//struct FiltersView_Previews: PreviewProvider {
// static var previews: some View {
// FiltersView()
// }
//}

View File

@ -146,11 +146,9 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
private let asset: AVURLAsset
// The content view needs to supply an intrinsicContentSize for the LargeImageViewController to handle layout/scrolling/zooming correctly
private var videoSize: CGSize?
override var intrinsicContentSize: CGSize {
// This is a really sucky workaround for the fact that in the content view, we don't have access to the size of the underlying video.
// There's probably some way of getting this from the AVPlayer/AVAsset directly
animationImage?.size ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
videoSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
init(attachment: Attachment, source: UIImageView) {
@ -163,6 +161,17 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
self.animationImage = source.image
self.player.play()
Task {
do {
if let track = try await asset.loadTracks(withMediaType: .video).first {
let (size, transform) = try await track.load(.naturalSize, .preferredTransform)
self.videoSize = size.applying(transform)
self.invalidateIntrinsicContentSize()
}
} catch {
}
}
}
required init?(coder: NSCoder) {

View File

@ -48,6 +48,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
private var prevZoomScale: CGFloat?
private var isGrayscale = false
private var contentViewSizeObservation: NSKeyValueObservation?
var isInteractivelyAnimatingDismissal: Bool = false {
didSet {
@ -127,6 +128,9 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
contentViewLeadingConstraint,
contentViewTopConstraint,
])
contentViewSizeObservation = (contentView as UIView).observe(\.bounds, changeHandler: { [unowned self] _, _ in
self.centerImage()
})
}
private func setupControls() {
@ -259,8 +263,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
centerImage()
let prevZoomScale = self.prevZoomScale ?? scrollView.minimumZoomScale
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true)

View File

@ -20,7 +20,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
var nextRange: RequestRange?
var searchResultsController: EditListSearchResultsContainerViewController!
var searchResultsController: SearchResultsViewController!
var searchController: UISearchController!
private var listRenamedCancellable: AnyCancellable?
@ -64,27 +64,22 @@ class EditListAccountsViewController: EnhancedTableViewController {
})
dataSource.editListAccountsController = self
searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in
Task {
await self.addAccount(id: accountID)
}
}
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
searchResultsController.following = true
searchResultsController.delegate = self
searchController = UISearchController(searchResultsController: searchResultsController)
searchController.hidesNavigationBarDuringPresentation = false
searchController.searchResultsUpdater = searchResultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
} else {
searchController.automaticallyShowsScopeBar = true
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder")
searchController.searchBar.placeholder = NSLocalizedString("Search accounts you follow", comment: "edit list search field placeholder")
searchController.searchBar.delegate = searchResultsController
searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"]
definesPresentationContext = true
navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked
}
navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
@ -206,3 +201,11 @@ extension EditListAccountsViewController: ToastableViewController {
extension EditListAccountsViewController: MenuActionProvider {
}
extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
func selectedSearchResult(account accountID: String) {
Task {
await addAccount(id: accountID)
}
}
}

View File

@ -1,178 +0,0 @@
//
// EditListSearchFollowingViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class EditListSearchFollowingViewController: EnhancedTableViewController {
private let mastodonController: MastodonController
private let didSelectAccount: (String) -> Void
private var dataSource: UITableViewDiffableDataSource<Section, String>!
private var query: String?
private var accountIDs: [String] = []
private var nextRange: RequestRange?
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
self.mastodonController = mastodonController
self.didSelectAccount = didSelectAccount
super.init(style: .grouped)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell")
dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
cell.delegate = self
cell.updateUI(accountID: itemIdentifier)
return cell
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if dataSource.snapshot().numberOfItems == 0 {
Task {
await load()
}
}
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
print("will display: \(indexPath)")
if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 {
Task {
await load()
}
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let id = dataSource.itemIdentifier(for: indexPath) else {
return
}
didSelectAccount(id)
}
private func load() async {
do {
let ownAccount = try await mastodonController.getOwnAccount()
let req = Account.getFollowing(ownAccount.id, range: nextRange ?? .default)
let (following, pagination) = try await mastodonController.run(req)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(accounts: following) {
continuation.resume()
}
}
accountIDs.append(contentsOf: following.lazy.map(\.id))
nextRange = pagination?.older
updateDataSource(appending: following.map(\.id))
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Following", in: self) { toast in
toast.dismissToast(animated: true)
await self.load()
}
self.showToast(configuration: config, animated: true)
}
}
private func updateDataSourceForQueryChanged() {
guard let query, !query.isEmpty else {
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
dataSource.apply(snapshot, animatingDifferences: true)
return
}
let ids = filterAccounts(ids: accountIDs, with: query)
var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.accounts) == nil {
snapshot.appendSections([.accounts])
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
}
snapshot.appendItems(ids)
dataSource.apply(snapshot, animatingDifferences: true)
// if there aren't any results for the current query, try to load more
if ids.isEmpty {
Task {
await load()
}
}
}
private func updateDataSource(appending ids: [String]) {
guard let query, !query.isEmpty else {
let snapshot = NSDiffableDataSourceSnapshot<Section, String>()
dataSource.apply(snapshot, animatingDifferences: true)
return
}
let ids = filterAccounts(ids: ids, with: query)
var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.accounts) == nil {
snapshot.appendSections([.accounts])
}
let existing = snapshot.itemIdentifiers(inSection: .accounts)
snapshot.appendItems(ids.filter { !existing.contains($0) })
dataSource.apply(snapshot, animatingDifferences: true)
// if there aren't any results for the current query, try to load more
if ids.isEmpty {
Task {
await load()
}
}
}
private func filterAccounts(ids: [String], with query: String) -> [String] {
let req = AccountMO.fetchRequest()
req.predicate = NSPredicate(format: "id in %@", ids)
let accounts = try! mastodonController.persistentContainer.viewContext.fetch(req)
return accounts
.map { (account) -> (AccountMO, Bool) in
let displayNameMatch = FuzzyMatcher.match(pattern: query, str: account.displayNameWithoutCustomEmoji)
let usernameMatch = FuzzyMatcher.match(pattern: query, str: account.acct)
return (account, displayNameMatch.matched || usernameMatch.matched)
}
.filter(\.1)
.map(\.0.id)
}
func updateQuery(_ query: String) {
self.query = query
updateDataSourceForQueryChanged()
}
}
extension EditListSearchFollowingViewController {
enum Section {
case accounts
}
}
extension EditListSearchFollowingViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension EditListSearchFollowingViewController: MenuActionProvider {
}

View File

@ -1,115 +0,0 @@
//
// EditListSearchResultsContainerViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/11/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Combine
class EditListSearchResultsContainerViewController: UIViewController {
private let mastodonController: MastodonController
private let didSelectAccount: (String) -> Void
private let searchResultsController: SearchResultsViewController
private let searchFollowingController: EditListSearchFollowingViewController
var mode = Mode.search {
willSet {
currentViewController.removeViewAndController()
}
didSet {
embedChild(currentViewController)
}
}
var currentViewController: UIViewController {
switch mode {
case .search:
return searchResultsController
case .following:
return searchFollowingController
}
}
private var currentQuery: String?
private var searchSubject = PassthroughSubject<String?, Never>()
private var cancellables = Set<AnyCancellable>()
init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) {
self.mastodonController = mastodonController
self.didSelectAccount = didSelectAccount
self.searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
self.searchFollowingController = EditListSearchFollowingViewController(mastodonController: mastodonController, didSelectAccount: didSelectAccount)
super.init(nibName: nil, bundle: nil)
self.searchResultsController.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
embedChild(currentViewController)
searchSubject
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.sink { [unowned self] in self.performSearch(query: $0) }
.store(in: &cancellables)
}
func performSearch(query: String?) {
guard var query = query?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return
}
if query.starts(with: "@") {
query = String(query.dropFirst())
}
guard query != self.currentQuery else {
return
}
self.currentQuery = query
switch mode {
case .search:
searchResultsController.performSearch(query: query)
case .following:
searchFollowingController.updateQuery(query)
}
}
enum Mode: Equatable {
case search, following
}
}
extension EditListSearchResultsContainerViewController: UISearchResultsUpdating {
func updateSearchResults(for searchController: UISearchController) {
searchSubject.send(searchController.searchBar.text)
}
}
extension EditListSearchResultsContainerViewController: UISearchBarDelegate {
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
performSearch(query: searchBar.text)
}
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
mode = selectedScope == 0 ? .search : .following
performSearch(query: searchBar.text)
}
}
extension EditListSearchResultsContainerViewController: SearchResultsViewControllerDelegate {
func selectedSearchResult(account accountID: String) {
didSelectAccount(accountID)
}
}

View File

@ -48,7 +48,7 @@ class ListTimelineViewController: TimelineViewController {
super.viewDidAppear(animated)
if presentEditOnAppear {
presentEdit(animated: animated)
presentEdit(animated: true)
presentEditOnAppear = false
}
}

View File

@ -12,10 +12,21 @@ import Duckable
@available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
(child as? TuskerRootViewController)?.stateRestorationActivity()
var activity = (child as? TuskerRootViewController)?.stateRestorationActivity()
if let compose = duckedViewController as? ComposeHostingController,
compose.draft.hasContent {
activity = UserActivityManager.addDuckedDraft(to: activity, draft: compose.draft)
}
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
if let draft = UserActivityManager.getDraft(from: activity),
let account = UserActivityManager.getAccount(from: activity) {
let mastodonController = MastodonController.getForAccount(account)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
_ = presentDuckable(compose, animated: false, isDucked: true)
}
(child as? TuskerRootViewController)?.restoreActivity(activity)
}

View File

@ -13,6 +13,7 @@ import Combine
protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
}
class MainSidebarViewController: UIViewController {
@ -208,28 +209,18 @@ class MainSidebarViewController: UIViewController {
}
private func reloadLists(_ lists: [List]) {
if let selectedItem,
case .list(let list) = selectedItem,
!lists.contains(where: { $0.id == list.id }) {
returnToPreviousItem()
}
var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
exploreSnapshot.append([.listsHeader])
exploreSnapshot.expand([.listsHeader])
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
exploreSnapshot.append([.addList], to: .listsHeader)
var selectedItem: Item?
if let selectedIndexPath = collectionView.indexPathsForSelectedItems?.first,
let item = dataSource.itemIdentifier(for: selectedIndexPath) {
if case .list(let list) = item,
let newList = lists.first(where: { $0.id == list.id }) {
selectedItem = .list(newList)
} else {
selectedItem = item
}
}
self.dataSource.apply(exploreSnapshot, to: .lists) {
if let selectedItem,
let indexPath = self.dataSource.indexPath(for: selectedItem) {
self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .centeredVertically)
}
}
self.dataSource.apply(exploreSnapshot, to: .lists)
}
@MainActor
@ -255,39 +246,39 @@ class MainSidebarViewController: UIViewController {
}
@objc private func reloadSavedHashtags() {
let selected = collectionView.indexPathsForSelectedItems?.first
let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
if let selectedItem,
case .savedHashtag(_) = selectedItem,
!hashtags.contains(selectedItem) {
returnToPreviousItem()
}
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
hashtagsSnapshot.append([.savedHashtagsHeader])
hashtagsSnapshot.expand([.savedHashtagsHeader])
let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader)
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) {
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags)
}
@objc private func reloadSavedInstances() {
let selected = collectionView.indexPathsForSelectedItems?.first
let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
if let selectedItem,
case .savedInstance(_) = selectedItem,
!instances.contains(selectedItem) {
returnToPreviousItem()
}
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
instancesSnapshot.append([.savedInstancesHeader])
instancesSnapshot.expand([.savedInstancesHeader])
let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
instancesSnapshot.append(instances, to: .savedInstancesHeader)
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) {
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
self.dataSource.apply(instancesSnapshot, to: .savedInstances)
}
@objc private func preferencesChanged() {
@ -306,10 +297,20 @@ class MainSidebarViewController: UIViewController {
}
}
private func returnToPreviousItem() {
let item = previouslySelectedItem ?? .tab(.timelines)
previouslySelectedItem = nil
select(item: item, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: item)
}
private func showAddList() {
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
) }) { list in
self.sidebarDelegate?.sidebar(self, didSelectItem: .list(list))
self.select(item: .list(list), animated: false)
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
list.presentEditOnAppear = true
self.sidebarDelegate?.sidebar(self, showViewController: list)
}
service.run()
}

View File

@ -101,7 +101,11 @@ class MainSplitViewController: UISplitViewController {
}
}
@objc func handleSidebarItemCommand(_ command: UICommand) {
@objc func handleSidebarItemCommand(_ sender: AnyObject) {
// workaround for crash when sender is not a UICommand, see #253 and FB11804009
guard let command = sender as? UICommand else {
return
}
let item: MainSidebarViewController.Item
if let index = command.propertyList as? Int {
item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
@ -238,7 +242,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
// These items are not selectable in the sidebar collection view, so this code is unreachable.
fatalError("unreachable")
fatalError("unexpected selected sidebar item: \(sidebar.selectedItem!)")
}
}
@ -313,7 +317,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case is ProfileDirectoryViewController:
exploreItem = .profileDirectory
default:
fatalError("unhandled second-level explore screen")
fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])")
}
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
@ -354,6 +358,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
}
select(item: item)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
secondaryNavController.viewControllers = [viewController]
}
}
fileprivate extension MainSidebarViewController.Item {
@ -391,8 +402,7 @@ extension MainSplitViewController: TuskerRootViewController {
return tabBarViewController.stateRestorationActivity()
} else {
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
let timeline = timelinePages.pageControllers[timelinePages.currentIndex] as! TimelineViewController
return timeline.stateRestorationActivity()
return timelinePages.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
return nil

View File

@ -13,11 +13,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
private var fastSwitcherConstraints: [NSLayoutConstraint] = []
@available(iOS, obsoleted: 16.0)
private var draftToPresentOnAppear: Draft?
var selectedTab: Tab {
return Tab(rawValue: selectedIndex)!
}
@ -85,6 +88,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
if let draftToPresentOnAppear {
self.draftToPresentOnAppear = nil
compose(editing: draftToPresentOnAppear, animated: true)
}
}
override func viewDidLayoutSubviews() {
@ -235,23 +243,39 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
extension MainTabBarViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC")
return nil
var activity: NSUserActivity?
if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController {
activity = timelinePages.stateRestorationActivity()
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find timeline/page VC")
}
return timelineVC.stateRestorationActivity()
if let presentedNav = presentedViewController as? UINavigationController,
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft)
}
return activity
}
func restoreActivity(_ activity: NSUserActivity) {
func restoreEditedDraft() {
// on iOS 16+, this is handled by the duckable container
if #unavailable(iOS 16.0),
let draft = UserActivityManager.getDraft(from: activity) {
draftToPresentOnAppear = draft
}
}
if activity.activityType == UserActivityType.showTimeline.rawValue {
let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController,
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
return
}
timelineVC.restoreActivity(activity)
timelinePages.restoreActivity(activity)
restoreEditedDraft()
} else if activity.activityType == UserActivityType.newPost.rawValue {
restoreEditedDraft()
return
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
}

View File

@ -22,7 +22,7 @@ struct MuteAccountView: View {
6 * 60 * 60,
24 * 60 * 60,
3 * 24 * 60 * 60,
7 * 60 * 60 * 60,
7 * 24 * 60 * 60,
]
return [
.init(value: 0, title: "Forever")
@ -40,8 +40,15 @@ struct MuteAccountView: View {
@State private var error: Error?
var body: some View {
NavigationView {
navigationViewContent
if #available(iOS 16.0, *) {
NavigationStack {
navigationViewContent
}
} else {
NavigationView {
navigationViewContent
}
.navigationViewStyle(.stack)
}
}
@ -107,7 +114,7 @@ struct MuteAccountView: View {
.disabled(isMuting)
}
.alertWithData("Erorr Muting", data: $error, actions: { error in
Button("Ok") {}
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})

View File

@ -8,6 +8,7 @@
import UIKit
import Pachyderm
import Sentry
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
@ -16,6 +17,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
private let followGroupCell = "followGroupCell"
private let followRequestCell = "followRequestCell"
private let pollCell = "pollCell"
private let updatedCell = "updatedCell"
private let unknownCell = "unknownCell"
weak var mastodonController: MastodonController!
@ -51,6 +53,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
tableView.register(UINib(nibName: "PollFinishedTableViewCell", bundle: .main), forCellReuseIdentifier: pollCell)
tableView.register(UINib(nibName: "StatusUpdatedNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: updatedCell)
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
}
@ -69,7 +72,18 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
fatalError()
}
cell.delegate = self
cell.updateUI(statusID: notification.status!.id, state: group.statusState!)
guard let status = notification.status else {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": notification.id,
"type": notification.kind.rawValue,
"created_at": notification.createdAt.formatted(.iso8601),
"account": notification.account.id,
]
SentrySDK.addBreadcrumb(crumb: crumb)
fatalError("missing status for mention notification")
}
cell.updateUI(statusID: status.id, state: group.statusState!)
return cell
case .favourite, .reblog:
@ -98,6 +112,13 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
cell.updateUI(notification: notification)
return cell
case .update:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: updatedCell, for: indexPath) as? StatusUpdatedNotificationTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(notification: notification)
return cell
case .unknown:
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text")
@ -105,6 +126,19 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
}
}
private func validateNotifications(_ notifications: [Pachyderm.Notification]) {
for notif in notifications where notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite) {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": notif.id,
"type": notif.kind.rawValue,
"created_at": notif.createdAt.formatted(.iso8601),
"account": notif.account.id,
]
SentrySDK.addBreadcrumb(crumb: crumb)
}
}
override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
let request = Client.getNotifications(excludeTypes: excludedTypes)
mastodonController.run(request) { (response) in
@ -113,6 +147,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(.failure(.client(error)))
case let .success(notifications, _):
self.validateNotifications(notifications)
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
if !notifications.isEmpty {
@ -143,6 +178,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(.failure(.client(error)))
case let .success(newNotifications, _):
self.validateNotifications(newNotifications)
if !newNotifications.isEmpty {
self.older = .before(id: newNotifications.last!.id, count: nil)
}
@ -174,6 +210,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(.failure(.client(error)))
case let .success(newNotifications, _):
self.validateNotifications(newNotifications)
guard !newNotifications.isEmpty else {
completion(.failure(.allCaughtUp))
return

View File

@ -147,7 +147,7 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
// no-op, don't show an error message
} catch let error as Error {
let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default))
alert.addAction(UIAlertAction(title: "OK", style: .default))
self.present(alert, animated: true)
}
}

View File

@ -11,6 +11,8 @@ import CoreData
struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared
@State private var imageCacheSize: Int64 = 0
@State private var mastodonCacheSize: Int64 = 0
var body: some View {
List {
@ -64,13 +66,42 @@ struct AdvancedPrefsView : View {
}
var cachingSection: some View {
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) {
Section {
Button(action: clearCache) {
Text("Clear Mastodon Cache")
}.foregroundColor(.red)
Button(action: clearImageCaches) {
Text("Clear Image Caches")
}.foregroundColor(.red)
} header: {
Text("Caching")
} footer: {
var s: AttributedString = "Clearing caches will restart the app."
if imageCacheSize != 0 {
s += AttributedString("\nImage cache size: \(ByteCountFormatter().string(fromByteCount: imageCacheSize))")
}
if mastodonCacheSize != 0 {
s += AttributedString("\nMastodon cache size: \(ByteCountFormatter().string(fromByteCount: mastodonCacheSize))")
}
return Text(s)
}.task {
imageCacheSize = [
ImageCache.avatars,
.headers,
.attachments,
.emojis,
].map {
$0.getDiskSizeInBytes() ?? 0
}.reduce(0, +)
mastodonCacheSize = LocalData.shared.accounts.map {
let descriptions = MastodonController.getForAccount($0).persistentContainer.persistentStoreDescriptions
return descriptions.map {
guard let url = $0.url else {
return 0
}
return FileManager.default.recursiveSize(url: url) ?? 0
}.reduce(0, +)
}.reduce(0, +)
}
}

View File

@ -59,6 +59,16 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline")
}
NavigationLink("Leading Swipe Actions") {
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)
.edgesIgnoringSafeArea(.all)
.navigationTitle("Leading Swipe Actions")
}
NavigationLink("Trailing Swipe Actions") {
SwipeActionsPrefsView(selection: $preferences.trailingStatusSwipeActions)
.edgesIgnoringSafeArea(.all)
.navigationTitle("Trailing Swipe Actions")
}
}
}
}

View File

@ -0,0 +1,122 @@
//
// SwipeActionsPrefsView.swift
// Tusker
//
// Created by Shadowfacts on 11/26/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import SwiftUI
struct SwipeActionsPrefsView: UIViewControllerRepresentable {
@Binding var selection: [StatusSwipeAction]
typealias UIViewControllerType = SwipeActionsPrefsViewController
func makeUIViewController(context: Context) -> SwipeActionsPrefsViewController {
return SwipeActionsPrefsViewController(selection: $selection)
}
func updateUIViewController(_ uiViewController: SwipeActionsPrefsViewController, context: Context) {
}
}
class SwipeActionsPrefsViewController: UIViewController, UICollectionViewDelegate {
@Binding var selection: [StatusSwipeAction]
private var collectionView: UICollectionView {
view as! UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(selection: Binding<[StatusSwipeAction]>) {
self._selection = selection
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
if dataSource.sectionIdentifier(for: sectionIndex) == .selected {
config.headerMode = .supplementary
}
return .list(using: config, layoutEnvironment: environment)
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, StatusSwipeAction> { cell, indexPath, item in
var config = cell.defaultContentConfiguration()
config.text = item.displayName
config.image = UIImage(systemName: item.systemImageName)
cell.contentConfiguration = config
cell.accessories = [.reorder(displayed: .always)]
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: itemIdentifier)
}
let headerCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
var config = supplementaryView.defaultContentConfiguration()
config.text = "Selected"
supplementaryView.contentConfiguration = config
}
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
return collectionView.dequeueConfiguredReusableSupplementary(using: headerCell, for: indexPath)
}
dataSource.reorderingHandlers.canReorderItem = { _ in
return true
}
dataSource.reorderingHandlers.didReorder = { [unowned self] transaction in
guard let selectedSection = transaction.sectionTransactions.first(where: { $0.sectionIdentifier == .selected }) else {
return
}
self.selection = self.selection.applying(selectedSection.difference)!
}
return dataSource
}
override func viewDidLoad() {
super.viewDidLoad()
setEditing(true, animated: false)
applySnapshot(animated: false)
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath),
let section = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
switch section {
case .selected:
selection.removeAll(where: { $0 == item })
case .remainder:
selection.append(item)
}
applySnapshot(animated: true)
}
private func applySnapshot(animated: Bool) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.selected, .remainder])
snapshot.appendItems(selection, toSection: .selected)
snapshot.appendItems(StatusSwipeAction.allCases.filter { !selection.contains($0) }, toSection: .remainder)
dataSource.apply(snapshot, animatingDifferences: animated)
}
enum Section {
case selected
case remainder
}
typealias Item = StatusSwipeAction
}

View File

@ -14,6 +14,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
weak var owner: ProfileViewController?
let mastodonController: MastodonController
let filterer: Filterer
private(set) var accountID: String!
let kind: Kind
var initialHeaderMode: HeaderMode?
@ -38,6 +39,9 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.kind = kind
self.owner = owner
self.mastodonController = owner.mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: .account)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
super.init(nibName: nil, bundle: nil)
@ -66,8 +70,11 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
}
if case .status(_, _, _) = item {
} else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else if case .status(_, _, _, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
@ -106,14 +113,20 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
}
}
.store(in: &cancellables)
filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged)
}
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState, Bool)> { [unowned self] cell, indexPath, item in
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.showPinned = item.2
cell.updateUI(statusID: item.0, state: item.1)
cell.showPinned = item.4
cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
}
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
@ -139,8 +152,14 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.headerCell = cell
return cell
}
case .status(id: let id, state: let state, pinned: let pinned):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, pinned))
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
let (result, precomputedContent) = filterResult(state: filterState, statusID: id)
switch result {
case .allow, .warn(_):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, precomputedContent, pinned))
case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
case .loadingIndicator:
return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
@ -225,7 +244,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
var snapshot = dataSource.snapshot()
let existingPinned = snapshot.itemIdentifiers(inSection: .pinned)
let items = statuses.map {
let item = Item.status(id: $0.id, state: .unknown, pinned: true)
let item = Item.status(id: $0.id, collapseState: .unknown, filterState: .unknown, pinned: true)
// try to keep the existing status state
if let existing = existingPinned.first(where: { $0 == item }) {
return existing
@ -238,6 +257,38 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
await apply(snapshot, animatingDifferences: true)
}
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
let status = {
let status = self.mastodonController.persistentContainer.status(for: statusID)!
// if the status is a reblog of another one, filter based on that one
return status.reblog ?? status
}
return filterer.resolve(state: state, status: status)
}
private func reapplyFilters(actionsChanged: Bool) {
let visible = collectionView.indexPathsForVisibleItems
let items = visible
.compactMap { dataSource.itemIdentifier(for: $0) }
.filter {
if case .status(_, _, _, _) = $0 {
return true
} else {
return false
}
}
guard !items.isEmpty else {
return
}
var snapshot = dataSource.snapshot()
if actionsChanged {
snapshot.reloadItems(items)
} else {
snapshot.reconfigureItems(items)
}
dataSource.apply(snapshot)
}
@objc func refresh() {
guard case .loaded = state else {
#if !targetEnvironment(macCatalyst)
@ -288,19 +339,19 @@ extension ProfileStatusesViewController {
typealias TimelineItem = String
case header(String)
case status(id: String, state: StatusState, pinned: Bool)
case status(id: String, collapseState: CollapseState, filterState: FilterState, pinned: Bool)
case loadingIndicator
case confirmLoadMore
static func fromTimelineItem(_ item: String) -> Self {
return .status(id: item, state: .unknown, pinned: false)
return .status(id: item, collapseState: .unknown, filterState: .unknown, pinned: false)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.header(a), .header(b)):
return a == b
case let (.status(id: a, state: _, pinned: ap), .status(id: b, state: _, pinned: bp)):
case let (.status(id: a, _, _, pinned: ap), .status(id: b, _, _, pinned: bp)):
return a == b && ap == bp
case (.loadingIndicator, .loadingIndicator):
return true
@ -316,7 +367,7 @@ extension ProfileStatusesViewController {
case .header(let id):
hasher.combine(0)
hasher.combine(id)
case .status(id: let id, state: _, pinned: let pinned):
case .status(id: let id, _, _, pinned: let pinned):
hasher.combine(1)
hasher.combine(id)
hasher.combine(pinned)
@ -338,7 +389,7 @@ extension ProfileStatusesViewController {
var isSelectable: Bool {
switch self {
case .status(id: _, state: _, pinned: _):
case .status(_, _, _, _):
return true
default:
return false
@ -445,11 +496,20 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
}
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard case .status(id: let id, state: let state, pinned: _) = dataSource.itemIdentifier(for: indexPath) else {
guard let item = dataSource.itemIdentifier(for: indexPath),
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: _) = item else {
return
}
let status = mastodonController.persistentContainer.status(for: id)!
selected(status: status.reblog?.id ?? id, state: state.copy())
if filterState.isWarning {
filterer.setResult(.allow, for: filterState)
collectionView.deselectItem(at: indexPath, animated: true)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
} else {
let status = mastodonController.persistentContainer.status(for: id)!
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
}
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
@ -482,6 +542,17 @@ extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
if let indexPath = collectionView.indexPath(for: cell),
let item = dataSource.itemIdentifier(for: indexPath),
case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item {
filterer.setResult(.allow, for: filterState)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension ProfileStatusesViewController: TabBarScrollableViewController {

View File

@ -40,6 +40,8 @@ class SearchResultsViewController: EnhancedTableViewController {
/// Types of results to search for. `nil` means all results will be included.
var resultTypes: [SearchResultType]? = nil
/// Whether to limit results to accounts the users is following.
var following: Bool? = nil
let searchSubject = PassthroughSubject<String?, Never>()
var currentQuery: String?
@ -77,7 +79,6 @@ class SearchResultsViewController: EnhancedTableViewController {
tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1),
])
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell)
@ -150,7 +151,7 @@ class SearchResultsViewController: EnhancedTableViewController {
activityIndicator.startAnimating()
errorLabel.isHidden = true
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10)
let request = Client.search(query: query, types: resultTypes, resolve: true, limit: 10, following: following)
mastodonController.run(request) { (response) in
switch response {
case let .success(results, _):
@ -247,7 +248,7 @@ extension SearchResultsViewController {
enum Item: Hashable {
case account(String)
case hashtag(Hashtag)
case status(String, StatusState)
case status(String, CollapseState)
func hash(into hasher: inout Hasher) {
switch self {

View File

@ -270,7 +270,7 @@ extension SearchViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in
UIMenu(children: self.actionsForHashtag(hashtag, sourceView: self.collectionView.cellForItem(at: indexPath)))
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self.collectionView.cellForItem(at: indexPath))))
}
case let .link(card):

View File

@ -14,7 +14,7 @@ class StatusActionAccountListViewController: UIViewController {
private let mastodonController: MastodonController
private let actionType: ActionType
private let statusID: String
private let statusState: StatusState
private let statusState: CollapseState
private var accountIDs: [String]?
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate.
@ -33,7 +33,7 @@ class StatusActionAccountListViewController: UIViewController {
- Parameter accountIDs The accounts that will be shown. If `nil` is passed, a request will be performed to load the accounts.
- Parameter mastodonController The `MastodonController` instance this view controller uses.
*/
init(actionType: ActionType, statusID: String, statusState: StatusState, accountIDs: [String]?, mastodonController: MastodonController) {
init(actionType: ActionType, statusID: String, statusState: CollapseState, accountIDs: [String]?, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.actionType = actionType
self.statusID = statusID
@ -80,7 +80,7 @@ class StatusActionAccountListViewController: UIViewController {
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
cell.delegate = self
cell.updateUI(statusID: self.statusID, state: self.statusState)
cell.updateUI(statusID: self.statusID, state: self.statusState, filterResult: .allow, precomputedContent: nil)
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self
@ -120,6 +120,10 @@ class StatusActionAccountListViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
if accountIDs == nil {
Task {
await loadAccounts()
@ -202,7 +206,7 @@ extension StatusActionAccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, sourceView: cell))
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
}
}
}
@ -256,6 +260,10 @@ extension StatusActionAccountListViewController: StatusCollectionViewCellDelegat
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
fatalError()
}
}
extension StatusActionAccountListViewController: StatusBarTappableViewController {

View File

@ -14,18 +14,15 @@ class HashtagTimelineViewController: TimelineViewController {
let hashtag: Hashtag
var toggleSaveButton: UIBarButtonItem!
var toggleSaveButtonTitle: String {
if isHashtagSaved {
return NSLocalizedString("Unsave", comment: "unsave hashtag button")
} else {
return NSLocalizedString("Save", comment: "save hashtag button")
}
}
private var isHashtagSaved: Bool {
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
}
private var isHashtagFollowed: Bool {
mastodonController.followedHashtags.contains(where: { $0.name == hashtag.name })
}
init(for hashtag: Hashtag, mastodonController: MastodonController) {
self.hashtag = hashtag
@ -39,19 +36,16 @@ class HashtagTimelineViewController: TimelineViewController {
override func viewDidLoad() {
super.viewDidLoad()
toggleSaveButton = UIBarButtonItem(title: toggleSaveButtonTitle, style: .plain, target: self, action: #selector(toggleSaveButtonPressed))
navigationItem.rightBarButtonItem = toggleSaveButton
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil)
let menu = UIMenu(children: [
// uncached so that the saved/followed updates every time
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
})
])
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
}
@objc func savedHashtagsChanged() {
toggleSaveButton.title = toggleSaveButtonTitle
}
// MARK: - Interaction
@objc func toggleSaveButtonPressed() {
private func toggleSave() {
let context = mastodonController.persistentContainer.viewContext
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(existing)
@ -61,4 +55,10 @@ class HashtagTimelineViewController: TimelineViewController {
mastodonController.persistentContainer.save(context: context)
}
private func toggleFollow() {
Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: self).toggleFollow()
}
}
}

View File

@ -69,10 +69,10 @@ class InstanceTimelineViewController: TimelineViewController {
toggleSaveButton.title = toggleSaveButtonTitle
}
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = browsingEnabled ? self : nil
cell.overrideMastodonController = mastodonController
cell.updateUI(statusID: id, state: state)
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {

View File

@ -10,9 +10,10 @@ import UIKit
import Pachyderm
import Combine
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController {
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
let timeline: Timeline
weak var mastodonController: MastodonController!
let filterer: Filterer
private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
@ -28,6 +29,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline
self.mastodonController = mastodonController
let filterContext: FilterV1.Context
switch timeline {
case .home, .list(id: _):
filterContext = .home
default:
filterContext = .public
}
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
super.init(nibName: nil, bundle: nil)
@ -59,6 +70,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if item.hideSeparators {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else if case .status(id: _, collapseState: _, filterState: let filterState) = item,
filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
@ -95,18 +110,29 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
}
filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged)
}
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
}
// separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: StatusState) {
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self
cell.updateUI(statusID: id, state: state)
if case .home = timeline {
cell.showFollowedHashtags = true
} else {
cell.showFollowedHashtags = false
}
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1)
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
}
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
}
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
cell.showsIndicator = false
@ -123,8 +149,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(id: let id, state: let state):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
case .status(id: let id, collapseState: let state, filterState: let filterState):
let (result, attributedString) = filterResult(state: filterState, statusID: id)
switch result {
case .allow, .warn(_):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, nil))
case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
case .gap:
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
case .loadingIndicator:
@ -224,14 +256,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
centerVisibleItem = allItems[centerVisible.row]
}
let ids = items.map {
if case .status(id: let id, state: _) = $0 {
if case .status(id: let id, _, _) = $0 {
return id
} else {
fatalError()
}
}
let centerVisibleID: String
if case .status(id: let id, state: _) = centerVisibleItem {
if case .status(id: let id, _, _) = centerVisibleItem {
centerVisibleID = id
} else {
fatalError()
@ -252,8 +284,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
private func doRestore() -> Bool {
guard let activity = activityToRestore,
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
guard let activity = activityToRestore else {
return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false
}
@ -262,7 +296,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
controller.restoreInitial {
var snapshot = dataSource.snapshot()
snapshot.appendSections([.statuses])
let items = statusIDs.map { Item.status(id: $0, state: .unknown) }
let items = statusIDs.map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
@ -297,6 +331,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false
}
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
let status = {
let status = self.mastodonController.persistentContainer.status(for: statusID)!
// if the status is a reblog of another one, filter based on that one
return status.reblog ?? status
}
return filterer.resolve(state: state, status: status)
}
private func reapplyFilters(actionsChanged: Bool) {
let visible = collectionView.indexPathsForVisibleItems
let items = visible
.compactMap { dataSource.itemIdentifier(for: $0) }
.filter {
if case .status(_, _, _) = $0 {
return true
} else {
return false
}
}
guard !items.isEmpty else {
return
}
var snapshot = dataSource.snapshot()
if actionsChanged {
// need to reload not just reconfigure because hidden posts use a separate cell type
snapshot.reloadItems(items)
} else {
// reconfigure when possible to avoid the content offset jumping around
snapshot.reconfigureItems(items)
}
dataSource.apply(snapshot)
}
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
guard let scene = notification.object as? UIScene,
// view.window is nil when the VC is not on screen
@ -312,16 +380,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
Task {
if case .notLoadedInitial = controller.state {
await controller.loadInitial()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
} else {
@MainActor
func loadNewerAndEndRefreshing() async {
await controller.loadNewer()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
}
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
if let presentItems {
let (_, presentItems) = await (loadNewerAndEndRefreshing(), try? loadInitial())
if let presentItems, !presentItems.isEmpty {
insertPresentItemsIfNecessary(presentItems)
}
}
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing()
#endif
}
}
@ -333,18 +409,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot()
let snapshot = dataSource.snapshot()
guard snapshot.indexOfSection(.statuses) != nil else {
return
}
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, state: _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user
if case .status(id: let firstID, _, _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, prompt the user
!presentItems.contains(firstID) {
// remove any existing gap, if there is one
if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...]))
}
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
// create a new snapshot to reset the timeline to the "present" state
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses])
snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
var config = ToastConfiguration(title: "Jump to present")
config.edge = .top
@ -353,12 +430,34 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
let origSnapshot = self.dataSource.snapshot()
let origItemAtTop: (Item, CGFloat)?
if let statusesSection = origSnapshot.indexOfSection(.statuses),
let indexPath = self.collectionView.indexPathsForVisibleItems.sorted().first(where: { $0.section == statusesSection }),
let cell = self.collectionView.cellForItem(at: indexPath),
let item = self.dataSource.itemIdentifier(for: indexPath) {
origItemAtTop = (item, cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top)
} else {
origItemAtTop = nil
}
self.dataSource.apply(snapshot, animatingDifferences: true) {
// TODO: we can't set prevScrollOffsetBeforeScrollToTop here to allow undoing the scroll-to-top
// because that would involve scrolling through unmeasured-cell which fucks up the content offset values.
// we probably need a data-source aware implementation of scrollToTop which uses item & offset w/in item
// to track the restore position
self.collectionView.scrollToItem(at: IndexPath(row: 0, section: 0), at: .top, animated: true)
var config = ToastConfiguration(title: "Go back")
config.edge = .top
config.systemImageName = "arrow.down"
config.dismissAutomaticallyAfter = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
// todo: it would be nice if we could animate this, but that doesn't work with the screen-position-maintaining stuff
if let (item, offset) = origItemAtTop {
self.applySnapshot(snapshot, maintainingScreenPosition: offset, ofItem: item)
} else {
self.dataSource.apply(origSnapshot, animatingDifferences: false)
}
}
self.showToast(configuration: config, animated: true)
}
}
self.showToast(configuration: config, animated: true)
@ -367,32 +466,35 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// NOTE: this only works when items are being inserted ABOVE the item to maintain
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
if let indexPath = dataSource.indexPath(for: itemToMaintain),
let cell = collectionView.cellForItem(at: indexPath) {
// subtract top safe area inset b/c scrollToItem at .top aligns the top of the cell to the top of the safe area
firstItemAfterOriginalGapOffsetFromTop = cell.convert(.zero, to: view).y - view.safeAreaInsets.top
}
applySnapshot(snapshot, maintainingScreenPosition: firstItemAfterOriginalGapOffsetFromTop, ofItem: itemToMaintain)
}
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingScreenPosition offsetFromTop: CGFloat, ofItem itemToMaintain: Item) {
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
dataSource.apply(snapshot, animatingDifferences: false) {
if let indexPathOfItemAfterOriginalGap = self.dataSource.indexPath(for: itemToMaintain) {
if let indexPathOfItemToMaintain = self.dataSource.indexPath(for: itemToMaintain) {
// scroll up until we've accumulated enough MEASURED height that we can put the
// firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemAfterOriginalGap
var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0
while true {
if cur.row <= 0 {
break
}
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop {
if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
break
}
cur = IndexPath(row: cur.row - 1, section: cur.section)
@ -402,7 +504,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
amountScrolledUp += attrs.size.height
}
self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop
self.collectionView.contentOffset.y -= offsetFromTop
}
snapshotView.removeFromSuperview()
@ -422,19 +524,19 @@ extension TimelineViewController {
enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String // status ID
case status(id: String, state: StatusState)
case status(id: String, collapseState: CollapseState, filterState: FilterState)
case gap
case loadingIndicator
case confirmLoadMore
case publicTimelineDescription
static func fromTimelineItem(_ id: String) -> Self {
return .status(id: id, state: .unknown)
return .status(id: id, collapseState: .unknown, filterState: .unknown)
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)):
case let (.status(id: a, _, _), .status(id: b, _, _)):
return a == b
case (.gap, .gap):
return true
@ -451,7 +553,7 @@ extension TimelineViewController {
func hash(into hasher: inout Hasher) {
switch self {
case .status(id: let id, state: _):
case .status(id: let id, _, _):
hasher.combine(0)
hasher.combine(id)
case .gap:
@ -476,7 +578,7 @@ extension TimelineViewController {
var isSelectable: Bool {
switch self {
case .publicTimelineDescription, .gap, .status(id: _, state: _):
case .publicTimelineDescription, .gap, .status(_, _, _):
return true
default:
return false
@ -506,7 +608,7 @@ extension TimelineViewController {
func loadNewer() async throws -> [TimelineItem] {
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
throw Error.noNewer
}
let newer = RequestRange.after(id: id, count: nil)
@ -530,7 +632,7 @@ extension TimelineViewController {
func loadOlder() async throws -> [TimelineItem] {
let snapshot = dataSource.snapshot()
let statusesSection = snapshot.indexOfSection(.statuses)!
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
guard case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
throw Error.noNewer
}
let older = RequestRange.before(id: id, count: nil)
@ -560,14 +662,14 @@ extension TimelineViewController {
switch direction {
case .above:
guard gapIndexPath.row > 0,
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
// not really the right error but w/e
throw Error.noGap
}
range = .before(id: id, count: nil)
case .below:
guard gapIndexPath.row < statusItemsCount - 1,
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
case .status(id: let id, _, _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
throw Error.noGap
}
range = .after(id: id, count: nil)
@ -617,13 +719,13 @@ extension TimelineViewController {
// if there is any overlap, the first overlapping item will be the first item below the gap
var indexOfFirstTimelineItemExistingBelowGap: Int?
if case .status(id: let id, state: _) = afterGap.first {
if case .status(id: let id, _, _) = afterGap.first {
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
}
// the end index of the range of timelineItems that don't yet exist in the data source
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
if toInsert.isEmpty {
addedItems = false
} else {
@ -645,7 +747,7 @@ extension TimelineViewController {
// if there's any overlap, last overlapping item will be the last item below the gap
var indexOfLastTimelineItemExistingAboveGap: Int?
if case .status(id: let id, state: _) = beforeGap.last {
if case .status(id: let id, _, _) = beforeGap.last {
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
}
@ -657,7 +759,7 @@ extension TimelineViewController {
} else {
startIndex = timelineItems.startIndex
}
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, collapseState: .unknown, filterState: .unknown) }
if toInsert.isEmpty {
addedItems = false
} else {
@ -721,10 +823,18 @@ extension TimelineViewController: UICollectionViewDelegate {
switch item {
case .publicTimelineDescription:
removeTimelineDescriptionCell()
case .status(id: let id, state: let state):
let status = mastodonController.persistentContainer.status(for: id)!
// if the status in the timeline is a reblog, show the status that it is a reblog of
selected(status: status.reblog?.id ?? id, state: state.copy())
case .status(id: let id, collapseState: let collapseState, filterState: let filterState):
if filterState.isWarning {
filterer.setResult(.allow, for: filterState)
collectionView.deselectItem(at: indexPath, animated: true)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
} else {
let status = mastodonController.persistentContainer.status(for: id)!
// if the status in the timeline is a reblog, show the status that it is a reblog of
selected(status: status.reblog?.id ?? id, state: collapseState.copy())
}
case .gap:
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true
@ -773,6 +883,17 @@ extension TimelineViewController: StatusCollectionViewCellDelegate {
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
}
}
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
if let indexPath = collectionView.indexPath(for: cell),
let item = dataSource.itemIdentifier(for: indexPath),
case .status(id: _, collapseState: _, filterState: let filterState) = item {
filterer.setResult(.allow, for: filterState)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension TimelineViewController: TabBarScrollableViewController {

View File

@ -7,6 +7,7 @@
//
import UIKit
import SwiftUI
class TimelinesPageViewController: SegmentedPageViewController {
@ -40,28 +41,42 @@ class TimelinesPageViewController: SegmentedPageViewController {
title = homeTitle
tabBarItem.image = UIImage(systemName: "house.fill")
let filtersItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(filtersPressed))
filtersItem.accessibilityLabel = "Filters"
navigationItem.leftBarButtonItem = filtersItem
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func stateRestorationActivity() -> NSUserActivity? {
return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) {
guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return
}
let index: Int
switch timeline {
case .home:
selectPage(at: 0, animated: false)
index = 0
case .public(local: false):
selectPage(at: 1, animated: false)
index = 1
case .public(local: true):
selectPage(at: 2, animated: false)
index = 2
default:
return
}
let timelineVC = pageControllers[currentIndex] as! TimelineViewController
selectPage(at: index, animated: false)
let timelineVC = pageControllers[index] as! TimelineViewController
timelineVC.restoreActivity(activity)
}
@objc private func filtersPressed() {
present(UIHostingController(rootView: FiltersView(mastodonController: mastodonController)), animated: true)
}
}

View File

@ -0,0 +1,13 @@
//
// CollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/29/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
protocol CollectionViewController: UIViewController {
var collectionView: UICollectionView! { get }
}

View File

@ -39,15 +39,15 @@ extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] {
func actionsForProfile(accountID: String, source: PopoverSource) -> [UIMenuElement] {
guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
var shareSection = [
openInSafariAction(url: account.url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forAccount: accountID, sourceView: sourceView)
self.navigationDelegate?.showMoreOptions(forAccount: accountID, source: source)
})
]
@ -95,24 +95,27 @@ extension MenuActionProvider {
]
}
func actionsForURL(_ url: URL, sourceView: UIView?) -> [UIAction] {
func actionsForURL(_ url: URL, source: PopoverSource) -> [UIAction] {
return [
openInSafariAction(url: url),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forURL: url, sourceView: sourceView)
self.navigationDelegate?.showMoreOptions(forURL: url, source: source)
})
]
}
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
let actionsSection: [UIMenuElement]
func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
var actionsSection: [UIMenuElement] = []
if let mastodonController = mastodonController,
mastodonController.loggedIn {
let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name)).first
let saveSubtitle = "Saved hashtags appear in the Explore section of Tusker"
let saveImage = UIImage(systemName: existing != nil ? "minus" : "plus")
actionsSection = [
createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in
UIAction(title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", subtitle: saveSubtitle, image: saveImage, handler: { (_) in
if let existing = existing {
context.delete(existing)
} else {
@ -121,13 +124,21 @@ extension MenuActionProvider {
mastodonController.persistentContainer.save(context: context)
})
]
} else {
actionsSection = []
if mastodonController.instanceFeatures.canFollowHashtags {
let existing = mastodonController.followedHashtags.first(where: { $0.name.lowercased() == name })
let subtitle = "Posts tagged with followed hashtags appear in your Home timeline"
let image = UIImage(systemName: existing != nil ? "person.badge.minus" : "person.badge.plus")
actionsSection.append(UIAction(title: existing != nil ? "Unfollow" : "Follow", subtitle: subtitle, image: image) { [unowned self] _ in
Task {
await ToggleFollowHashtagService(hashtag: hashtag, presenter: navigationDelegate!).toggleFollow()
}
})
}
}
let shareSection: [UIMenuElement]
if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, sourceView: sourceView)
shareSection = actionsForURL(url, source: source)
} else {
shareSection = []
}
@ -138,16 +149,16 @@ extension MenuActionProvider {
]
}
func actionsForStatus(_ status: StatusMO, sourceView: UIView?, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
func actionsForStatus(_ status: StatusMO, source: PopoverSource, includeStatusButtonActions: Bool = true) -> [UIMenuElement] {
guard let mastodonController = mastodonController else { return [] }
guard let accountID = mastodonController.accountInfo?.id else {
// user is logged out
return [
openInSafariAction(url: status.url!),
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
})
]
}
@ -271,9 +282,9 @@ extension MenuActionProvider {
} else {
Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)")
}
shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self, weak sourceView] (_) in
shareSection.append(createAction(identifier: "share", title: "Share", systemImageName: "square.and.arrow.up", handler: { [weak self] (_) in
guard let self = self else { return }
self.navigationDelegate?.showMoreOptions(forStatus: status.id, sourceView: sourceView)
self.navigationDelegate?.showMoreOptions(forStatus: status.id, source: source)
}))
addOpenInNewWindow(actions: &shareSection, activity: UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID))
@ -355,6 +366,17 @@ extension MenuActionProvider {
}
}
private func handleSuccess(title: String) {
if let toastable = self.toastableViewController {
var config = ToastConfiguration(title: title)
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
}
private func relationshipAction(accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
let relationship = Task {
@ -380,14 +402,26 @@ extension MenuActionProvider {
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let accountID = relationship.accountID
let following = relationship.following
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [weak self] _ in
let request = (following ? Account.unfollow : Account.follow)(accountID)
let requested = relationship.requested
let title = following ? "Unfollow" : requested ? "Cancel Request" : "Follow"
let imageName = following || requested ? "person.badge.minus" : "person.badge.plus"
return createAction(identifier: "follow", title: title, systemImageName: imageName) { [weak self] _ in
let request = (following || requested ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { response in
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(following ? "Unf" : "F")ollowing")
self?.handleError(error, title: following ? "Error Unfollowing" : requested ? "Error Cancelinng Request" : "Error Following")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
if requested { // was requested, now cancelled
self?.handleSuccess(title: "Follow Request Cancelled")
} else if following { // was following, now unfollowed
self?.handleSuccess(title: "Unfollowed")
} else if relationship.followRequested { // was not following, now requested
self?.handleSuccess(title: "Request Sent")
} else { // was not following, not now requested, assume success
self?.handleSuccess(title: "Followed")
}
}
}
}
@ -407,6 +441,7 @@ extension MenuActionProvider {
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: "\(block ? "B" : "Unb")locked")
}
}
}
@ -415,8 +450,11 @@ extension MenuActionProvider {
return { [weak self] (_: UIAction) in
let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
mastodonController.run(req) { response in
if case .failure(let error) = response {
switch response {
case .failure(let error):
self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
case .success(_, _):
self?.handleSuccess(title: "Domain \(block ? "B" : "Unb")locked")
}
}
}
@ -446,6 +484,7 @@ extension MenuActionProvider {
self?.handleError(error, title: "Error Unmuting")
case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: "Unmuted")
}
}
}

View File

@ -13,6 +13,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
let titles: [String]
let pageControllers: [UIViewController]
private var initialIndex = 0
private(set) var currentIndex = 0
var segmentedControl: UISegmentedControl!
@ -43,7 +44,7 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
view.backgroundColor = .systemBackground
selectPage(at: 0, animated: false)
selectPage(at: initialIndex, animated: false)
addKeyCommand(MenuController.prevSubTabCommand)
addKeyCommand(MenuController.nextSubTabCommand)
@ -57,6 +58,10 @@ class SegmentedPageViewController: UIPageViewController, UIPageViewControllerDel
}
func selectPage(at index: Int, animated: Bool) {
guard isViewLoaded else {
initialIndex = index
return
}
let direction: UIPageViewController.NavigationDirection = index - currentIndex > 0 ? .forward : .reverse
setViewControllers([pageControllers[index]], direction: direction, animated: animated)
navigationItem.title = pageControllers[index].title

View File

@ -56,7 +56,7 @@ class SplitNavigationController: UIViewController {
let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow {
tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true)
} else if let sender = sender as? UIViewController,
let collectionView = sender.view as? UICollectionView {
let collectionView = (sender as? CollectionViewController)?.collectionView ?? sender.view as? UICollectionView {
// the collection view's animation speed is weirdly fast, so we do it slower
UIView.animate(withDuration: 0.5, delay: 0) {
collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) }

View File

@ -0,0 +1,61 @@
//
// SemiCaseSensitiveComparator.swift
// Tusker
//
// Created by Shadowfacts on 11/30/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
/// A comparator that sorts objects with a string key path case insensitively unless they're the same, in which case uppercase comes after lowercase.
struct SemiCaseSensitiveComparator: SortComparator {
var order: SortOrder = .forward
typealias Compared = String
static func keyPath<Object>(_ keyPath: KeyPath<Object, String>) -> KeyPathComparator<Object> {
return KeyPathComparator(keyPath, comparator: SemiCaseSensitiveComparator())
}
func compare(_ lhs: String, _ rhs: String) -> ComparisonResult {
let result = doCompare(lhs, rhs)
if case .reverse = order {
switch result {
case .orderedDescending:
return .orderedAscending
case .orderedAscending:
return .orderedDescending
case .orderedSame:
return .orderedSame
}
} else {
return result
}
}
private func doCompare(_ lhs: String, _ rhs: String) -> ComparisonResult {
for (l, r) in zip(lhs, rhs) {
let lLower = l.lowercased()
let rLower = r.lowercased()
if lLower < rLower {
return .orderedAscending
} else if lLower > rLower {
return .orderedDescending
} else {
if l < r {
return .orderedDescending
} else if l > r {
return .orderedAscending
}
}
}
if lhs.count > rhs.count {
return .orderedDescending
} else if lhs.count < rhs.count {
return .orderedAscending
} else {
return .orderedSame
}
}
}

View File

@ -91,10 +91,37 @@ class UserActivityManager {
return activity
}
static func addDuckedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
if let activity {
activity.addUserInfoEntries(from: [
"duckedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
}
}
static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
if let activity {
activity.addUserInfoEntries(from: [
"editedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
}
}
static func getDraft(from activity: NSUserActivity) -> Draft? {
guard activity.activityType == UserActivityType.newPost.rawValue,
let str = activity.userInfo?["draftID"] as? String,
let uuid = UUID(uuidString: str) else {
let idStr: String?
if activity.activityType == UserActivityType.newPost.rawValue {
idStr = activity.userInfo?["draftID"] as? String
} else {
idStr = activity.userInfo?["duckedDraftID"] as? String ?? activity.userInfo?["editedDraftID"] as? String
}
guard let idStr,
let uuid = UUID(uuidString: idStr) else {
return nil
}
return DraftsManager.shared.getBy(id: uuid)

View File

@ -13,7 +13,7 @@ import Pachyderm
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController! { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController
}
extension TuskerNavigationDelegate {
@ -59,7 +59,7 @@ extension TuskerNavigationDelegate {
message += " This can happen if you do not have an app installed for '\(scheme)://' URLs."
}
let alert = UIAlertController(title: "Invalid URL", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
present(alert, animated: true)
}
}
@ -76,7 +76,7 @@ extension TuskerNavigationDelegate {
}
}
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController {
return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
}
@ -84,11 +84,11 @@ extension TuskerNavigationDelegate {
self.selected(status: statusID, state: .unknown)
}
func selected(status statusID: String, state: StatusState) {
func selected(status statusID: String, state: CollapseState) {
show(conversation(mainStatusID: statusID, state: state), sender: self)
}
func compose(editing draft: Draft) {
func compose(editing draft: Draft, animated: Bool = true) {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions()
@ -97,20 +97,20 @@ extension TuskerNavigationDelegate {
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
if #available(iOS 16.0, *),
presentDuckable(compose) {
presentDuckable(compose, animated: animated) {
return
} else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = compose
present(nav, animated: true)
present(nav, animated: animated)
}
}
}
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil) {
func compose(inReplyToID: String? = nil, mentioningAcct: String? = nil, animated: Bool = true) {
let draft = apiController.createDraft(inReplyToID: inReplyToID, mentioningAcct: mentioningAcct)
compose(editing: draft)
compose(editing: draft, animated: animated)
}
func loadingLargeImage(url: URL, cache: ImageCache, description: String?, animatingFrom sourceView: UIImageView) -> LoadingLargeImageViewController {
@ -159,21 +159,21 @@ extension TuskerNavigationDelegate {
return UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: nil)
}
func showMoreOptions(forStatus statusID: String, sourceView: UIView?) {
func showMoreOptions(forStatus statusID: String, source: PopoverSource) {
let vc = moreOptions(forStatus: statusID)
vc.popoverPresentationController?.sourceView = sourceView
source.apply(to: vc)
present(vc, animated: true)
}
func showMoreOptions(forURL url: URL, sourceView: UIView?) {
func showMoreOptions(forURL url: URL, source: PopoverSource) {
let vc = moreOptions(forURL: url)
vc.popoverPresentationController?.sourceView = sourceView
source.apply(to: vc)
present(vc, animated: true)
}
func showMoreOptions(forAccount accountID: String, sourceView: UIView?) {
func showMoreOptions(forAccount accountID: String, source: PopoverSource) {
let vc = moreOptions(forAccount: accountID)
vc.popoverPresentationController?.sourceView = sourceView
source.apply(to: vc)
present(vc, animated: true)
}
@ -183,8 +183,35 @@ extension TuskerNavigationDelegate {
show(vc, sender: self)
}
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: StatusState, accountIDs: [String]?) -> StatusActionAccountListViewController {
func statusActionAccountList(action: StatusActionAccountListViewController.ActionType, statusID: String, statusState state: CollapseState, accountIDs: [String]?) -> StatusActionAccountListViewController {
return StatusActionAccountListViewController(actionType: action, statusID: statusID, statusState: state, accountIDs: accountIDs, mastodonController: apiController)
}
}
enum PopoverSource {
case none
case view(WeakHolder<UIView>)
case barButtonItem(WeakHolder<UIBarButtonItem>)
func apply(to viewController: UIViewController) {
if let popoverPresentationController = viewController.popoverPresentationController {
switch self {
case .none:
break
case .view(let view):
popoverPresentationController.sourceView = view.object
case .barButtonItem(let item):
popoverPresentationController.barButtonItem = item.object
}
}
}
static func view(_ view: UIView?) -> Self {
.view(WeakHolder(view))
}
static func barButtonItem(_ item: UIBarButtonItem?) -> Self {
.barButtonItem(WeakHolder(item))
}
}

View File

@ -109,7 +109,7 @@ extension AccountTableViewCell: MenuPreviewProvider {
guard let mastodonController = mastodonController else { return nil }
return (
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) },
actions: { self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? [] }
actions: { self.delegate?.actionsForProfile(accountID: self.accountID, source: .view(self.avatarImageView)) ?? [] }
)
}
}

View File

@ -14,6 +14,7 @@ import WebURL
import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
private let dataDetectorsScheme = "x-apple-data-detectors"
class ContentTextView: LinkTextView, BaseEmojiLabel {
@ -21,14 +22,19 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
weak var overrideMastodonController: MastodonController?
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
var defaultFont: UIFont = .systemFont(ofSize: 17)
var defaultColor: UIColor = .label
var paragraphStyle: NSParagraphStyle = {
let style = NSMutableParagraphStyle()
// 2 points is enough that it doesn't make things look weirdly spaced out, but leaves a slight gap when there are multiple lines of emojis
style.lineSpacing = 2
return style
}()
private var htmlConverter = HTMLConverter()
var defaultFont: UIFont {
_read { yield htmlConverter.font }
_modify { yield &htmlConverter.font }
}
var defaultColor: UIColor {
_read { yield htmlConverter.color }
_modify { yield &htmlConverter.color }
}
var paragraphStyle: NSParagraphStyle {
_read { yield htmlConverter.paragraphStyle }
_modify { yield &htmlConverter.paragraphStyle }
}
private(set) var hasEmojis = false
@ -84,99 +90,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// MARK: - HTML Parsing
func setTextFromHtml(_ html: String) {
let doc = try! SwiftSoup.parse(html)
let body = doc.body()!
let attributedText = attributedTextForHTMLNode(body)
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace()
mutAttrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: mutAttrString.fullRange)
self.attributedText = mutAttrString
}
private func attributedTextForHTMLNode(_ node: Node, usePreformattedText: Bool = false) -> NSAttributedString {
switch node {
case let node as TextNode:
let text: String
if usePreformattedText {
text = node.getWholeText()
} else {
text = node.text()
}
return NSAttributedString(string: text, attributes: [.font: defaultFont, .foregroundColor: defaultColor])
case let node as Element:
let attributed = NSMutableAttributedString(string: "", attributes: [.font: defaultFont, .foregroundColor: defaultColor])
for child in node.getChildNodes() {
attributed.append(attributedTextForHTMLNode(child, usePreformattedText: usePreformattedText || node.tagName() == "pre"))
}
switch node.tagName() {
case "br":
// need to specify defaultFont here b/c otherwise it uses the default 12pt Helvetica which
// screws up its determination of the line height making multiple lines of emojis squash together
attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont]))
case "a":
let href = try! node.attr("href")
if let webURL = WebURL(href),
let url = URL(webURL) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
} else if let url = URL(string: href) {
attributed.addAttribute(.link, value: url, range: attributed.fullRange)
}
case "p":
attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: defaultFont]))
case "em", "i":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = defaultFont
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitItalic)!, range: attributed.fullRange)
case "strong", "b":
let currentFont: UIFont
if attributed.length == 0 {
currentFont = defaultFont
} else {
currentFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? defaultFont
}
attributed.addAttribute(.font, value: currentFont.withTraits(.traitBold)!, range: attributed.fullRange)
case "del":
attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange)
case "code":
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
case "pre":
attributed.append(NSAttributedString(string: "\n\n"))
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), 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
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: defaultFont.pointSize, weight: .regular)])
} else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: defaultFont])
} else {
bullet = NSAttributedString()
}
attributed.insert(bullet, at: 0)
attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont]))
default:
break
}
return attributed
default:
fatalError("Unexpected node type \(type(of: node))")
}
self.attributedText = htmlConverter.convert(html)
}
// MARK: - Interaction
@ -198,7 +112,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
let location = recognizer.location(in: self)
if let (link, range) = getLinkAtPoint(location) {
if let (link, range) = getLinkAtPoint(location),
link.scheme != dataDetectorsScheme {
let text = (self.text as NSString).substring(with: range)
handleLinkTapped(url: link, text: text)
}
@ -287,9 +202,15 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
extension ContentTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool {
// generally disable the text view's link interactions, we handle tapping links ourself with a gesture recognizer
// the builtin data detectors use the x-apple-data-detectors scheme, and we allow the text view to handle those itself
return URL.scheme == "x-apple-data-detectors"
if URL.scheme == dataDetectorsScheme {
return true
} else {
// otherwise, regular taps are handled by the gesture recognizer, but the accessibility interaction to select links with the rotor goes through here
// and this seems to be the only way of overriding what it does
handleLinkTapped(url: URL, text: (text as NSString).substring(with: characterRange))
return false
}
}
}
@ -313,11 +234,11 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
let text = (self.text as NSString).substring(with: range)
let actions: [UIMenuElement]
if let mention = self.getMention(for: link, text: text) {
actions = self.actionsForProfile(accountID: mention.id, sourceView: self)
actions = self.actionsForProfile(accountID: mention.id, source: .view(self))
} else if let tag = self.getHashtag(for: link, text: text) {
actions = self.actionsForHashtag(tag, sourceView: self)
actions = self.actionsForHashtag(tag, source: .view(self))
} else {
actions = self.actionsForURL(link, sourceView: self)
actions = self.actionsForURL(link, source: .view(self))
}
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
}

View File

@ -9,6 +9,7 @@
import UIKit
import Pachyderm
import SwiftSoup
import Sentry
class ActionNotificationGroupTableViewCell: UITableViewCell {
@ -66,7 +67,17 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
self.group = group
guard let firstNotification = group.notifications.first else { fatalError() }
let status = firstNotification.status!
guard let status = firstNotification.status else {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": firstNotification.id,
"type": firstNotification.kind.rawValue,
"created_at": firstNotification.createdAt.formatted(.iso8601),
"account": firstNotification.account.id,
]
SentrySDK.addBreadcrumb(crumb: crumb)
fatalError("missing status for favorite/reblog notification")
}
self.statusID = status.id
updateUIForPreferences()

View File

@ -214,7 +214,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
}
}, actions: {
if accountIDs.count == 1 {
return self.delegate?.actionsForProfile(accountID: accountIDs.first!, sourceView: self) ?? []
return self.delegate?.actionsForProfile(accountID: accountIDs.first!, source: .view(self)) ?? []
} else {
return []
}

View File

@ -52,7 +52,7 @@ class PollFinishedTableViewCell: UITableViewCell {
displayNameLabel.text = notification.account.displayName
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
let doc = try! SwiftSoup.parse(status.content)
let doc = try! SwiftSoup.parseBodyFragment(status.content)
statusContentLabel.text = try! doc.text()
pollView.updateUI(status: status, poll: poll)
@ -114,7 +114,7 @@ extension PollFinishedTableViewCell: MenuPreviewProvider {
return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: {
delegate.actionsForStatus(status, sourceView: self)
delegate.actionsForStatus(status, source: .view(self))
})
}
}

View File

@ -0,0 +1,114 @@
//
// StatusUpdatedNotificationTableViewCell.swift
// Tusker
//
// Created by Shadowfacts on 11/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftSoup
class StatusUpdatedNotificationTableViewCell: UITableViewCell {
weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
@IBOutlet weak var timestampLabel: UILabel!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var contentLabel: UILabel!
private var notification: Pachyderm.Notification?
private var updateTimestampWorkItem: DispatchWorkItem?
override func awakeFromNib() {
super.awakeFromNib()
timestampLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
]
]), size: 0)
timestampLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
displayNameLabel.adjustsFontForContentSizeCategory = true
}
func updateUI(notification: Pachyderm.Notification) {
guard notification.kind == .update,
let status = notification.status else {
return
}
self.notification = notification
updateTimestamp()
displayNameLabel.text = notification.account.displayName
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id)
let doc = try! SwiftSoup.parseBodyFragment(status.content)
contentLabel.text = try! doc.text()
}
private func updateTimestamp() {
guard let notification else { return }
timestampLabel.text = notification.createdAt.timeAgoString()
let delay: DispatchTimeInterval?
switch notification.createdAt.timeAgo().1 {
case .second:
delay = .seconds(10)
case .minute:
delay = .seconds(60)
default:
delay = nil
}
if let delay = delay {
if updateTimestampWorkItem == nil {
updateTimestampWorkItem = DispatchWorkItem { [weak self] in
self?.updateTimestamp()
}
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: updateTimestampWorkItem!)
}
} else {
updateTimestampWorkItem = nil
}
}
override func prepareForReuse() {
super.prepareForReuse()
updateTimestampWorkItem?.cancel()
updateTimestampWorkItem = nil
}
}
extension StatusUpdatedNotificationTableViewCell: SelectableTableViewCell {
func didSelectCell() {
guard let delegate,
let status = notification?.status else {
return
}
let vc = delegate.conversation(mainStatusID: status.id, state: .unknown)
delegate.show(vc)
}
}
extension StatusUpdatedNotificationTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let delegate,
let statusID = notification?.status?.id,
let status = delegate.apiController.persistentContainer.status(for: statusID) else {
return nil
}
return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: {
delegate.actionsForStatus(status, source: .view(self))
})
}
}

View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21507" 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="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="124" id="KGk-i7-Jjw" customClass="StatusUpdatedNotificationTableViewCell" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="124"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="124"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="ZWN-ni-RLP">
<rect key="frame" x="74" y="11" width="230" height="102"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="HLB-Iv-HTR">
<rect key="frame" x="0.0" y="0.0" width="230" height="20.333333333333332"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="A post was edited" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="mBx-jm-6sU">
<rect key="frame" x="0.0" y="0.0" width="206" height="20.333333333333332"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="2m" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="04d-Lt-yL5">
<rect key="frame" x="206" y="0.0" width="24" height="20.333333333333332"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Person" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="F5w-FN-c33" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="24.333333333333336" width="230" height="20.333333333333336"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="17"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="A4y-Se-5rW">
<rect key="frame" x="0.0" y="48.666666666666657" width="230" height="53.333333333333343"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="pencil" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="3pZ-j9-PPP">
<rect key="frame" x="36" y="11" width="30" height="31"/>
<constraints>
<constraint firstAttribute="width" constant="30" id="FOx-Ib-CH7"/>
<constraint firstAttribute="height" constant="30" id="UNQ-xp-O8B"/>
</constraints>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</imageView>
</subviews>
<constraints>
<constraint firstItem="3pZ-j9-PPP" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="6Aw-t0-5vM"/>
<constraint firstItem="ZWN-ni-RLP" firstAttribute="top" secondItem="H2p-sc-9uM" secondAttribute="topMargin" id="88f-jC-7Dk"/>
<constraint firstItem="ZWN-ni-RLP" firstAttribute="leading" secondItem="3pZ-j9-PPP" secondAttribute="trailing" constant="8" id="R68-9I-Bnh"/>
<constraint firstAttribute="bottomMargin" secondItem="ZWN-ni-RLP" secondAttribute="bottom" id="eCm-M2-qlS"/>
<constraint firstItem="ZWN-ni-RLP" firstAttribute="leading" secondItem="H2p-sc-9uM" secondAttribute="leadingMargin" constant="58" id="r84-Xe-N1F"/>
<constraint firstAttribute="trailingMargin" secondItem="ZWN-ni-RLP" secondAttribute="trailing" id="w0X-u7-BPp"/>
</constraints>
</tableViewCellContentView>
<viewLayoutGuide key="safeArea" id="njF-e1-oar"/>
<connections>
<outlet property="contentLabel" destination="A4y-Se-5rW" id="1j0-QT-bzy"/>
<outlet property="displayNameLabel" destination="F5w-FN-c33" id="q3K-Od-YxV"/>
<outlet property="timestampLabel" destination="04d-Lt-yL5" id="VeH-73-9Gh"/>
</connections>
<point key="canvasLocation" x="74.809160305343511" y="16.901408450704228"/>
</tableViewCell>
</objects>
<resources>
<image name="pencil" catalog="system" width="128" height="113"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -34,7 +34,8 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var moreButton: VisualEffectImageButton!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var followsYouLabel: UILabel!
@IBOutlet weak var lockImageView: UIImageView!
@IBOutlet weak var relationshipLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView!
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
@ -76,8 +77,8 @@ class ProfileHeaderView: UIView {
usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
usernameLabel.adjustsFontForContentSizeCategory = true
followsYouLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
followsYouLabel.adjustsFontForContentSizeCategory = true
relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
relationshipLabel.adjustsFontForContentSizeCategory = true
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.adjustsFontForContentSizeCategory = true
@ -117,10 +118,11 @@ class ProfileHeaderView: UIView {
updateUIForPreferences()
usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked
updateImages(account: account)
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, sourceView: moreButton) ?? [])
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton)) ?? [])
noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note)
@ -148,6 +150,7 @@ class ProfileHeaderView: UIView {
accessibilityElements = [
displayNameLabel!,
usernameLabel!,
relationshipLabel!,
noteTextView!,
fieldsView!,
moreButton!,
@ -161,7 +164,22 @@ class ProfileHeaderView: UIView {
return
}
followsYouLabel.isHidden = !relationship.followedBy
var relationshipStr: String?
if relationship.following && relationship.followedBy {
relationshipStr = "You follow each other"
} else if relationship.following {
relationshipStr = "You follow"
} else if relationship.followedBy {
relationshipStr = "Follows you"
} else if relationship.blocking {
relationshipStr = "You block"
}
if let relationshipStr {
relationshipLabel.text = relationshipStr
relationshipLabel.isHidden = false
} else {
relationshipLabel.isHidden = true
}
}
@objc private func updateUIForPreferences() {

View File

@ -40,7 +40,7 @@
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="10" adjustsLetterSpacingToFitWidth="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vcl-Gl-kXl" customClass="EmojiLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="144" y="206" width="254" height="32"/>
<rect key="frame" x="144" y="206" width="254" height="29"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
@ -60,7 +60,7 @@
</userDefinedRuntimeAttributes>
</view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq">
<rect key="frame" x="16" y="419" width="398" height="443"/>
<rect key="frame" x="16" y="266" width="398" height="596"/>
<subviews>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Follows you" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="UF8-nI-KVj">
<rect key="frame" x="0.0" y="0.0" width="75.5" height="0.0"/>
@ -83,7 +83,7 @@
</constraints>
</view>
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0">
<rect key="frame" x="0.0" y="403.5" width="382" height="32"/>
<rect key="frame" x="0.0" y="403.5" width="382" height="185"/>
<segments>
<segment title="Posts"/>
<segment title="Posts and Replies"/>
@ -94,7 +94,7 @@
</connections>
</segmentedControl>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz">
<rect key="frame" x="0.0" y="442.5" width="398" height="0.5"/>
<rect key="frame" x="0.0" y="595.5" width="398" height="0.5"/>
<color key="backgroundColor" systemColor="separatorColor"/>
<constraints>
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/>
@ -108,46 +108,57 @@
<constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
</constraints>
</stackView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL">
<rect key="frame" x="144" y="238" width="254" height="18"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<stackView opaque="NO" contentMode="scaleToFill" alignment="center" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="jwU-EH-hmC">
<rect key="frame" x="144" y="235" width="103.5" height="23"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" text="@username" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1C3-Pd-QiL">
<rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" image="lock.fill" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="KNY-GD-beC">
<rect key="frame" x="85" y="1.5" width="18.5" height="19.5"/>
<color key="tintColor" systemColor="secondaryLabelColor"/>
<preferredSymbolConfiguration key="preferredSymbolConfiguration" weight="light"/>
</imageView>
</subviews>
</stackView>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="1C3-Pd-QiL" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="23a-no-Gjj"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="jwU-EH-hmC" secondAttribute="trailing" constant="16" id="0VP-ri-Io5"/>
<constraint firstItem="dgG-dR-lSv" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="6Q0-q5-Ju6"/>
<constraint firstItem="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
<constraint firstItem="1C3-Pd-QiL" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="OpB-YM-gyu"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="TkY-oK-if4" secondAttribute="bottom" id="HBR-rg-RxO"/>
<constraint firstItem="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" constant="-8" id="ZB4-ys-9zP"/>
<constraint firstItem="1C3-Pd-QiL" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="d0z-X6-Sig"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="1C3-Pd-QiL" secondAttribute="trailing" constant="16" id="pcH-vi-2zH"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="oGR-6M-gdd"/>
<constraint firstAttribute="trailing" secondItem="u4P-3i-gEq" secondAttribute="trailing" id="ph6-NT-A02"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" relation="greaterThanOrEqual" secondItem="wT9-2J-uSY" secondAttribute="bottom" constant="8" id="tKQ-6d-Z55"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" relation="greaterThanOrEqual" secondItem="vcl-Gl-kXl" secondAttribute="bottom" constant="8" id="xDD-rx-gC0"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" constant="8" id="tKQ-6d-Z55"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="top" secondItem="jwU-EH-hmC" secondAttribute="bottom" priority="999" constant="8" id="xDD-rx-gC0"/>
</constraints>
<connections>
<outlet property="avatarContainerView" destination="wT9-2J-uSY" id="yEm-h7-tfq"/>
<outlet property="avatarImageView" destination="TkY-oK-if4" id="bSJ-7z-j4w"/>
<outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/>
<outlet property="followsYouLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
<outlet property="headerImageView" destination="dgG-dR-lSv" id="HXT-v4-2iX"/>
<outlet property="lockImageView" destination="KNY-GD-beC" id="9EJ-iM-Eos"/>
<outlet property="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
</connections>
<point key="canvasLocation" x="-590" y="117"/>
@ -155,6 +166,7 @@
</objects>
<resources>
<image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="lock.fill" catalog="system" width="125" height="128"/>
<systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>

View File

@ -57,7 +57,7 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
private(set) var statusState: StatusState!
private(set) var statusState: CollapseState!
var collapsible = false {
didSet {
collapseButton.isHidden = !collapsible
@ -130,7 +130,7 @@ class BaseStatusTableViewCell: UITableViewCell {
}
}
final func updateUI(statusID: String, state: StatusState) {
final func updateUI(statusID: String, state: CollapseState) {
createObserversIfNecessary()
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
@ -142,7 +142,7 @@ class BaseStatusTableViewCell: UITableViewCell {
doUpdateUI(status: status, state: state)
}
func doUpdateUI(status: StatusMO, state: StatusState) {
func doUpdateUI(status: StatusMO, state: CollapseState) {
self.statusState = state
let account = status.account
@ -182,8 +182,8 @@ class BaseStatusTableViewCell: UITableViewCell {
updateStatusIconsForPreferences(status)
if state.unknown {
layoutIfNeeded()
state.resolveFor(status: status, height: contentTextView.bounds.height)
// for some reason the height here can't be computed correctly, so we fallback to the old hack of just considering raw length
state.resolveFor(status: status, height: 0, textLength: contentTextView.attributedText.length)
if state.collapsible! && showStatusAutomatically {
state.collapsed = false
}
@ -209,7 +209,7 @@ class BaseStatusTableViewCell: UITableViewCell {
// keep menu in sync with changed states e.g. bookmarked, muted
// do not include reply action here, because the cell already contains a button for it
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeStatusButtonActions: false) ?? [])
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, source: .view(moreButton), includeStatusButtonActions: false) ?? [])
pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController
@ -336,7 +336,6 @@ class BaseStatusTableViewCell: UITableViewCell {
super.prepareForReuse()
avatarRequest?.cancel()
attachmentsView.attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
showStatusAutomatically = false
}
@ -409,7 +408,7 @@ class BaseStatusTableViewCell: UITableViewCell {
}
@IBAction func morePressed() {
delegate?.showMoreOptions(forStatus: statusID, sourceView: moreButton)
delegate?.showMoreOptions(forStatus: statusID, source: .view(moreButton))
}
@objc func accountPressed() {

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