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 # 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. This build is a hotfix for the CW button in the Compose screen not working. The previous build's changelog is attached below.
Bugfixes: Bugfixes:

View File

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

View File

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

View File

@ -1,5 +1,5 @@
// //
// Filter.swift // FilterV1.swift
// Pachyderm // Pachyderm
// //
// Created by Shadowfacts on 9/9/18. // Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation import Foundation
public class Filter: Decodable { public struct FilterV1: Decodable {
public let id: String public let id: String
public let phrase: String public let phrase: String
private let context: [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> { public static func update(_ filterID: String, phrase: String, context: [Context], irreversible: Bool, wholeWord: Bool, expiresIn: TimeInterval?) -> Request<FilterV1> {
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([ return Request<FilterV1>(method: .put, path: "/api/v1/filters/\(filterID)", body: ParametersBody([
"phrase" => (phrase ?? filter.phrase), "phrase" => phrase,
"irreversible" => (irreversible ?? filter.irreversible), "whole_word" => wholeWord,
"whole_word" => (wholeWord ?? filter.wholeWord), "expires_in" => expiresIn,
"expires_at" => (expiresAt ?? filter.expiresAt) ] + "context" => context.contextStrings))
] + "context" => (context?.contextStrings ?? filter.context)))
} }
public static func delete(_ filter: Filter) -> Request<Empty> { public static func delete(_ filterID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filter.id)") return Request<Empty>(method: .delete, path: "/api/v1/filters/\(filterID)")
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
@ -45,16 +44,17 @@ public class Filter: Decodable {
} }
} }
extension Filter { extension FilterV1 {
public enum Context: String, Decodable { public enum Context: String, Decodable, CaseIterable {
case home case home
case notifications case notifications
case `public` case `public`
case thread case thread
case account
} }
} }
extension Array where Element == Filter.Context { extension Array where Element == FilterV1.Context {
var contextStrings: [String] { var contextStrings: [String] {
return map { $0.rawValue } 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 public let url: WebURL
/// Only present when returned from the trending hashtags endpoint /// Only present when returned from the trending hashtags endpoint
public let history: [History]? public let history: [History]?
/// Only present on Mastodon >= 4 and when logged in
public let following: Bool?
public init(name: String, url: URL) { public init(name: String, url: URL) {
self.name = name self.name = name
self.url = WebURL(url)! self.url = WebURL(url)!
self.history = nil self.history = nil
self.following = nil
} }
public required init(from decoder: Decoder) throws { 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 // pixelfed (possibly others) don't fully escape special characters in the hashtag url
self.url = try container.decode(WebURL.self, forKey: .url) self.url = try container.decode(WebURL.self, forKey: .url)
self.history = try container.decodeIfPresent([History].self, forKey: .history) 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 { public func encode(to encoder: Encoder) throws {
@ -35,12 +39,22 @@ public class Hashtag: Codable {
try container.encode(name, forKey: .name) try container.encode(name, forKey: .name)
try container.encode(url, forKey: .url) try container.encode(url, forKey: .url)
try container.encodeIfPresent(history, forKey: .history) 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 { private enum CodingKeys: String, CodingKey {
case name case name
case url case url
case history case history
case following
} }
} }

View File

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

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public enum Timeline { public enum Timeline: Equatable {
case home case home
case `public`(local: Bool) case `public`(local: Bool)
case tag(hashtag: String) 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 { static func =>(name: String, focus: (Float, Float)?) -> Parameter {
guard let focus = focus else { return Parameter(name: name, value: nil) } guard let focus = focus else { return Parameter(name: name, value: nil) }
return Parameter(name: name, value: "\(focus.0),\(focus.1)") return Parameter(name: name, value: "\(focus.0),\(focus.1)")

View File

@ -1,5 +1,5 @@
// //
// StatusState.swift // CollapseState.swift
// Pachyderm // Pachyderm
// //
// Created by Shadowfacts on 11/24/19. // Created by Shadowfacts on 11/24/19.
@ -8,7 +8,7 @@
import Foundation import Foundation
public class StatusState: Equatable { public class CollapseState: Equatable {
public var collapsible: Bool? public var collapsible: Bool?
public var collapsed: Bool? public var collapsed: Bool?
@ -21,8 +21,8 @@ public class StatusState: Equatable {
self.collapsed = collapsed self.collapsed = collapsed
} }
public func copy() -> StatusState { public func copy() -> CollapseState {
return StatusState(collapsible: self.collapsible, collapsed: self.collapsed) return CollapseState(collapsible: self.collapsible, collapsed: self.collapsed)
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
@ -30,11 +30,11 @@ public class StatusState: Equatable {
hasher.combine(collapsed) hasher.combine(collapsed)
} }
public static var unknown: StatusState { public static var unknown: CollapseState {
StatusState(collapsible: nil, collapsed: nil) 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 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 private(set) var notifications: [Notification]
public let id: String public let id: String
public let kind: Notification.Kind public let kind: Notification.Kind
public let statusState: StatusState? public let statusState: CollapseState?
init?(notifications: [Notification]) { init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import Combine
class MastodonController: ObservableObject { class MastodonController: ObservableObject {
@ -47,7 +48,11 @@ class MastodonController: ObservableObject {
@Published private(set) var nodeInfo: NodeInfo! @Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var instanceFeatures = InstanceFeatures() @Published private(set) var instanceFeatures = InstanceFeatures()
@Published private(set) var lists: [List] = [] @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 pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask? private var ownInstanceRequest: URLSessionTask?
@ -61,6 +66,29 @@ class MastodonController: ObservableObject {
self.accountInfo = nil self.accountInfo = nil
self.client = Client(baseURL: instanceURL, session: .appDefault) self.client = Client(baseURL: instanceURL, session: .appDefault)
self.transient = transient 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 @discardableResult
@ -120,13 +148,25 @@ class MastodonController: ObservableObject {
}) })
} }
func initialize() async throws { @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()
Task {
do {
async let ownAccount = try getOwnAccount() async let ownAccount = try getOwnAccount()
async let ownInstance = try getOwnInstance() async let ownInstance = try getOwnInstance()
_ = try await (ownAccount, ownInstance) _ = try await (ownAccount, ownInstance)
loadLists() loadLists()
async let _ = await loadFilters()
} catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
}
}
} }
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
@ -230,7 +270,6 @@ class MastodonController: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
self.ownInstanceRequest = nil self.ownInstanceRequest = nil
self.instance = instance self.instance = instance
self.instanceFeatures.update(instance: instance, nodeInfo: self.nodeInfo)
for completion in self.pendingOwnInstanceRequestCallbacks { for completion in self.pendingOwnInstanceRequestCallbacks {
completion(.success(instance)) completion(.success(instance))
@ -248,9 +287,6 @@ class MastodonController: ObservableObject {
case let .success(nodeInfo, _): case let .success(nodeInfo, _):
DispatchQueue.main.async { DispatchQueue.main.async {
self.nodeInfo = nodeInfo 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 run(req) { response in
if case .success(let lists, _) = response { if case .success(let lists, _) = response {
DispatchQueue.main.async { 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) { func addedList(_ list: List) {
var new = self.lists var new = self.lists
new.append(list) new.append(list)
new.sort { $0.title < $1.title } new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
self.lists = new self.lists = new
} }
@ -304,23 +340,55 @@ class MastodonController: ObservableObject {
if let index = new.firstIndex(where: { $0.id == list.id }) { if let index = new.firstIndex(where: { $0.id == list.id }) {
new[index] = list new[index] = list
} }
new.sort(using: ListComparator()) new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title))
self.lists = new self.lists = new
} }
} @MainActor
private func loadFollowedHashtags() async {
updateFollowedHashtags()
private struct ListComparator: SortComparator { let req = Client.getFollowedHashtags()
typealias Compared = List if let (hashtags, _) = try? await run(req) {
self.persistentContainer.updateFollowedHashtags(hashtags) {
var underlying = String.Comparator(options: .caseInsensitive) if case .success(let hashtags) = $0 {
self.followedHashtags = hashtags
var order: SortOrder {
get { underlying.order }
set { underlying.order = newValue }
}
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 { extension DiskCache {

View File

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

View File

@ -11,7 +11,7 @@ import UIKit
class ImageDataCache { class ImageDataCache {
private let memory: MemoryCache<Entry> private let memory: MemoryCache<Entry>
private let disk: DiskCache<Data>? let disk: DiskCache<Data>?
private let storeOriginalDataInMemory: Bool private let storeOriginalDataInMemory: Bool
private let desiredPixelSize: CGSize? 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 storeDescription.type = NSInMemoryStoreType
persistentStoreDescriptions = [storeDescription] persistentStoreDescriptions = [storeDescription]
} else { } else {
super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) super.init(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
} }
loadPersistentStores { (description, error) in 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) { @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
let changes = hasChangedSavedHashtagsOrInstances(notification) let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags { if changes.hashtags {

View File

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

View File

@ -28,6 +28,24 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </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"> <entity name="Relationship" representedClassName="RelationshipMO" syncable="YES">
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="blocking" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <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 Foundation
import Pachyderm import Pachyderm
extension StatusState { extension CollapseState {
func resolveFor(status: StatusMO, height: CGFloat) { func resolveFor(status: StatusMO, height: CGFloat, textLength: Int? = nil) {
let longEnoughToCollapse: Bool let longEnoughToCollapse: Bool
if Preferences.shared.collapseLongPosts, if Preferences.shared.collapseLongPosts,
height > 500 { height > 500 || (textLength != nil && textLength! > 500) {
longEnoughToCollapse = true longEnoughToCollapse = true
} else { } else {
longEnoughToCollapse = false 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 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) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }

View File

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

View File

@ -177,6 +177,7 @@ extension MastodonController {
var acctsToMention = [String]() var acctsToMention = [String]()
var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
var localOnly = false
var contentWarning = "" var contentWarning = ""
if let inReplyToID = inReplyToID, if let inReplyToID = inReplyToID,
@ -184,6 +185,7 @@ extension MastodonController {
acctsToMention.append(inReplyTo.account.acct) acctsToMention.append(inReplyTo.account.acct)
acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct))
visibility = min(visibility, inReplyTo.visibility) visibility = min(visibility, inReplyTo.visibility)
localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly
if !inReplyTo.spoilerText.isEmpty { if !inReplyTo.spoilerText.isEmpty {
switch Preferences.shared.contentWarningCopyMode { switch Preferences.shared.contentWarningCopyMode {
@ -213,6 +215,7 @@ extension MastodonController {
draft.text = acctsToMention.map { "@\($0) " }.joined() draft.text = acctsToMention.map { "@\($0) " }.joined()
draft.initialText = draft.text draft.initialText = draft.text
draft.visibility = visibility draft.visibility = visibility
draft.localOnly = localOnly
draft.contentWarning = contentWarning draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty 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 showIsStatusReplyIcon = false
@Published var alwaysShowStatusVisibilityIcon = false @Published var alwaysShowStatusVisibilityIcon = false
@Published var hideActionsInTimeline = false @Published var hideActionsInTimeline = false
@Published var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
// MARK: Composing // MARK: Composing
@Published var defaultPostVisibility = Status.Visibility.public @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) let controller = MastodonController.getForAccount(account)
session.mastodonController = controller session.mastodonController = controller
Task { controller.initialize()
try? await controller.initialize()
}
guard let rootVC = viewController(for: activity, mastodonController: controller) else { guard let rootVC = viewController(for: activity, mastodonController: controller) else {
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil) UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)

View File

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

View File

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

View File

@ -58,6 +58,13 @@ class AccountListViewController: UIViewController {
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
}
} }
extension AccountListViewController { extension AccountListViewController {
@ -81,7 +88,7 @@ extension AccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController) ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in } 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 return vc
case .gifv: case .gifv:
// Passing the source view to the LargeImageGifvContentView is a crappy workaround for not // 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 // 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). // 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, // 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. // 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]!) let gifvContentView = LargeImageGifvContentView(attachment: attachment, source: sourceViews[index]!)

View File

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

View File

@ -176,6 +176,19 @@ struct ComposeAutocompleteEmojisView: View {
@State private var emojis: [Emoji] = [] @State private var emojis: [Emoji] = []
@ScaledMetric private var emojiSize = 30 @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 { var body: some View {
// When exapnded, the toggle button should be at the top. When collapsed, it should be centered. // When exapnded, the toggle button should be at the top. When collapsed, it should be centered.
HStack(alignment: expanded ? .top : .center, spacing: 0) { HStack(alignment: expanded ? .top : .center, spacing: 0) {
@ -214,7 +227,9 @@ struct ComposeAutocompleteEmojisView: View {
private var verticalGrid: some View { private var verticalGrid: some View {
ScrollView { ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
ForEach(emojis, id: \.shortcode) { (emoji) in ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
Section {
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
Button { Button {
uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):") uiState.currentInput?.autocomplete(with: ":\(emoji.shortcode):")
} label: { } label: {
@ -222,6 +237,20 @@ struct ComposeAutocompleteEmojisView: View {
.frame(height: emojiSize) .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)
}
}
}
} }
.padding(.all, 8) .padding(.all, 8)
} }

View File

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

View File

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

View File

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

View File

@ -107,7 +107,6 @@ struct ComposeView: View {
globalFrameOutsideList = frame globalFrameOutsideList = frame
} }
}) })
.navigationTitle(navTitle)
.sheet(isPresented: $uiState.isShowingDraftsList) { .sheet(isPresented: $uiState.isShowingDraftsList) {
DraftsView(currentDraft: draft, mastodonController: mastodonController) DraftsView(currentDraft: draft, mastodonController: mastodonController)
} }
@ -203,23 +202,19 @@ struct ComposeView: View {
private var header: some View { private var header: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
ComposeCurrentAccount() ComposeCurrentAccount()
.accessibilitySortPriority(1)
Spacer() Spacer()
Text(verbatim: charactersRemaining.description) Text(verbatim: charactersRemaining.description)
.foregroundColor(charactersRemaining < 0 ? .red : .secondary) .foregroundColor(charactersRemaining < 0 ? .red : .secondary)
.font(Font.body.monospacedDigit()) .font(Font.body.monospacedDigit())
.accessibility(label: Text(charactersRemaining < 0 ? "\(-charactersRemaining) characters too many" : "\(charactersRemaining) characters remaining")) .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) }.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 { private var cancelButton: some View {
Button(action: self.cancel) { Button(action: self.cancel) {
Text("Cancel") Text("Cancel")
@ -236,6 +231,7 @@ struct ComposeView: View {
} label: { } label: {
Text("Post") Text("Post")
} }
.keyboardShortcut(.return, modifiers: .command)
.disabled(!postButtonEnabled) .disabled(!postButtonEnabled)
} }

View File

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

View File

@ -174,7 +174,7 @@ extension ProfileDirectoryViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController) return ProfileViewController(accountID: account.id, mastodonController: self.mastodonController)
} actionProvider: { (_) in } 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) return UIMenu(children: actions)
} }

View File

@ -98,7 +98,7 @@ extension TrendingHashtagsViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in } 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> { 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.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 let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
cell.indicator.startAnimating() cell.indicator.startAnimating()
@ -119,7 +120,7 @@ extension TrendingStatusesViewController {
case statuses case statuses
} }
enum Item: Hashable { enum Item: Hashable {
case status(id: String, state: StatusState) case status(id: String, state: CollapseState)
case loadingIndicator case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
@ -206,6 +207,10 @@ extension TrendingStatusesViewController: StatusCollectionViewCellDelegate {
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
} }
} }
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
fatalError()
}
} }
extension TrendingStatusesViewController: StatusBarTappableViewController { 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 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 { 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. videoSize ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
// There's probably some way of getting this from the AVPlayer/AVAsset directly
animationImage?.size ?? CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
} }
init(attachment: Attachment, source: UIImageView) { init(attachment: Attachment, source: UIImageView) {
@ -163,6 +161,17 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
self.animationImage = source.image self.animationImage = source.image
self.player.play() 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) { required init?(coder: NSCoder) {

View File

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

View File

@ -20,7 +20,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
var nextRange: RequestRange? var nextRange: RequestRange?
var searchResultsController: EditListSearchResultsContainerViewController! var searchResultsController: SearchResultsViewController!
var searchController: UISearchController! var searchController: UISearchController!
private var listRenamedCancellable: AnyCancellable? private var listRenamedCancellable: AnyCancellable?
@ -64,27 +64,22 @@ class EditListAccountsViewController: EnhancedTableViewController {
}) })
dataSource.editListAccountsController = self dataSource.editListAccountsController = self
searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts])
Task { searchResultsController.following = true
await self.addAccount(id: accountID) searchResultsController.delegate = self
}
}
searchController = UISearchController(searchResultsController: searchResultsController) searchController = UISearchController(searchResultsController: searchResultsController)
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
searchController.searchResultsUpdater = searchResultsController searchController.searchResultsUpdater = searchResultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
} else {
searchController.automaticallyShowsScopeBar = true
}
searchController.searchBar.autocapitalizationType = .none 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.delegate = searchResultsController
searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"]
definesPresentationContext = true definesPresentationContext = true
navigationItem.searchController = searchController navigationItem.searchController = searchController
navigationItem.hidesSearchBarWhenScrolling = false 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)) 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: 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) super.viewDidAppear(animated)
if presentEditOnAppear { if presentEditOnAppear {
presentEdit(animated: animated) presentEdit(animated: true)
presentEditOnAppear = false presentEditOnAppear = false
} }
} }

View File

@ -12,10 +12,21 @@ import Duckable
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: TuskerRootViewController { extension DuckableContainerViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? { 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) { 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) (child as? TuskerRootViewController)?.restoreActivity(activity)
} }

View File

@ -13,6 +13,7 @@ import Combine
protocol MainSidebarViewControllerDelegate: AnyObject { protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
} }
class MainSidebarViewController: UIViewController { class MainSidebarViewController: UIViewController {
@ -208,28 +209,18 @@ class MainSidebarViewController: UIViewController {
} }
private func reloadLists(_ lists: [List]) { 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>() var exploreSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
exploreSnapshot.append([.listsHeader]) exploreSnapshot.append([.listsHeader])
exploreSnapshot.expand([.listsHeader]) exploreSnapshot.expand([.listsHeader])
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
exploreSnapshot.append([.addList], to: .listsHeader) exploreSnapshot.append([.addList], to: .listsHeader)
var selectedItem: Item? self.dataSource.apply(exploreSnapshot, to: .lists)
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)
}
}
} }
@MainActor @MainActor
@ -255,39 +246,39 @@ class MainSidebarViewController: UIViewController {
} }
@objc private func reloadSavedHashtags() { @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>() var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
hashtagsSnapshot.append([.savedHashtagsHeader]) hashtagsSnapshot.append([.savedHashtagsHeader])
hashtagsSnapshot.expand([.savedHashtagsHeader]) hashtagsSnapshot.expand([.savedHashtagsHeader])
let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader) hashtagsSnapshot.append(hashtags, to: .savedHashtagsHeader)
hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader) hashtagsSnapshot.append([.addSavedHashtag], to: .savedHashtagsHeader)
self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags, animatingDifferences: false) { self.dataSource.apply(hashtagsSnapshot, to: .savedHashtags)
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
} }
@objc private func reloadSavedInstances() { @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>() var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
instancesSnapshot.append([.savedInstancesHeader]) instancesSnapshot.append([.savedInstancesHeader])
instancesSnapshot.expand([.savedInstancesHeader]) instancesSnapshot.expand([.savedInstancesHeader])
let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
instancesSnapshot.append(instances, to: .savedInstancesHeader) instancesSnapshot.append(instances, to: .savedInstancesHeader)
instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader) instancesSnapshot.append([.addSavedInstance], to: .savedInstancesHeader)
self.dataSource.apply(instancesSnapshot, to: .savedInstances, animatingDifferences: false) { self.dataSource.apply(instancesSnapshot, to: .savedInstances)
if let selected = selected {
self.collectionView.selectItem(at: selected, animated: false, scrollPosition: .centeredVertically)
}
}
} }
@objc private func preferencesChanged() { @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() { private func showAddList() {
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
) }) { list in ) }) { 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() 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 let item: MainSidebarViewController.Item
if let index = command.propertyList as? Int { if let index = command.propertyList as? Int {
item = .tab(MainTabBarViewController.Tab(rawValue: index)!) item = .tab(MainTabBarViewController.Tab(rawValue: index)!)
@ -238,7 +242,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance: case .discoverHeader, .listsHeader, .addList, .savedHashtagsHeader, .addSavedHashtag, .savedInstancesHeader, .addSavedInstance:
// These items are not selectable in the sidebar collection view, so this code is unreachable. // 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: case is ProfileDirectoryViewController:
exploreItem = .profileDirectory exploreItem = .profileDirectory
default: default:
fatalError("unhandled second-level explore screen") fatalError("unhandled second-level explore screen: \(tabNavigationStack[1])")
} }
} }
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend) transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: 1, prepend: toPrepend)
@ -354,6 +358,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
} }
select(item: item) 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 { fileprivate extension MainSidebarViewController.Item {
@ -391,8 +402,7 @@ extension MainSplitViewController: TuskerRootViewController {
return tabBarViewController.stateRestorationActivity() return tabBarViewController.stateRestorationActivity()
} else { } else {
if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController { if let timelinePages = navigationStackFor(item: .tab(.timelines))?.first as? TimelinesPageViewController {
let timeline = timelinePages.pageControllers[timelinePages.currentIndex] as! TimelineViewController return timelinePages.stateRestorationActivity()
return timeline.stateRestorationActivity()
} else { } else {
stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity") stateRestorationLogger.fault("MainSplitViewController: Unable to create state restoration activity")
return nil return nil

View File

@ -13,11 +13,14 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var composePlaceholder: UIViewController! private var composePlaceholder: UIViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController!
private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView! private var fastSwitcherIndicator: FastAccountSwitcherIndicatorView!
private var fastSwitcherConstraints: [NSLayoutConstraint] = [] private var fastSwitcherConstraints: [NSLayoutConstraint] = []
@available(iOS, obsoleted: 16.0)
private var draftToPresentOnAppear: Draft?
var selectedTab: Tab { var selectedTab: Tab {
return Tab(rawValue: selectedIndex)! return Tab(rawValue: selectedIndex)!
} }
@ -85,6 +88,11 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)") stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
if let draftToPresentOnAppear {
self.draftToPresentOnAppear = nil
compose(editing: draftToPresentOnAppear, animated: true)
}
} }
override func viewDidLayoutSubviews() { override func viewDidLayoutSubviews() {
@ -235,23 +243,39 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: .timelines) as! UINavigationController let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, var activity: NSUserActivity?
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else { if let timelinePages = nav.viewControllers.first as? TimelinesPageViewController {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration actiivty, couldn't find timeline/page VC") activity = timelinePages.stateRestorationActivity()
return nil } 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 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 { if activity.activityType == UserActivityType.showTimeline.rawValue {
let nav = viewController(for: .timelines) as! UINavigationController let nav = viewController(for: .timelines) as! UINavigationController
guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController, guard let timelinePages = nav.viewControllers.first as? TimelinesPageViewController else {
let timelineVC = timelinePages.pageControllers[timelinePages.currentIndex] as? TimelineViewController else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC") stateRestorationLogger.fault("MainTabBarViewController: Unable to restore timeline activity, couldn't find VC")
return return
} }
timelineVC.restoreActivity(activity) timelinePages.restoreActivity(activity)
restoreEditedDraft()
} else if activity.activityType == UserActivityType.newPost.rawValue {
restoreEditedDraft()
return
} else { } else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)") 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, 6 * 60 * 60,
24 * 60 * 60, 24 * 60 * 60,
3 * 24 * 60 * 60, 3 * 24 * 60 * 60,
7 * 60 * 60 * 60, 7 * 24 * 60 * 60,
] ]
return [ return [
.init(value: 0, title: "Forever") .init(value: 0, title: "Forever")
@ -40,9 +40,16 @@ struct MuteAccountView: View {
@State private var error: Error? @State private var error: Error?
var body: some View { var body: some View {
if #available(iOS 16.0, *) {
NavigationStack {
navigationViewContent
}
} else {
NavigationView { NavigationView {
navigationViewContent navigationViewContent
} }
.navigationViewStyle(.stack)
}
} }
private var navigationViewContent: some View { private var navigationViewContent: some View {
@ -107,7 +114,7 @@ struct MuteAccountView: View {
.disabled(isMuting) .disabled(isMuting)
} }
.alertWithData("Erorr Muting", data: $error, actions: { error in .alertWithData("Erorr Muting", data: $error, actions: { error in
Button("Ok") {} Button("OK") {}
}, message: { error in }, message: { error in
Text(error.localizedDescription) Text(error.localizedDescription)
}) })

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import Sentry
class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> { class NotificationsTableViewController: DiffableTimelineLikeTableViewController<NotificationsTableViewController.Section, NotificationsTableViewController.Item> {
@ -16,6 +17,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
private let followGroupCell = "followGroupCell" private let followGroupCell = "followGroupCell"
private let followRequestCell = "followRequestCell" private let followRequestCell = "followRequestCell"
private let pollCell = "pollCell" private let pollCell = "pollCell"
private let updatedCell = "updatedCell"
private let unknownCell = "unknownCell" private let unknownCell = "unknownCell"
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
@ -51,6 +53,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell) tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell) tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
tableView.register(UINib(nibName: "PollFinishedTableViewCell", bundle: .main), forCellReuseIdentifier: pollCell) 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) tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
} }
@ -69,7 +72,18 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
fatalError() fatalError()
} }
cell.delegate = self 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 return cell
case .favourite, .reblog: case .favourite, .reblog:
@ -98,6 +112,13 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
cell.updateUI(notification: notification) cell.updateUI(notification: notification)
return cell 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: case .unknown:
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath) let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text") 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) { override func loadInitialItems(completion: @escaping (LoadResult) -> Void) {
let request = Client.getNotifications(excludeTypes: excludedTypes) let request = Client.getNotifications(excludeTypes: excludedTypes)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
@ -113,6 +147,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(notifications, _): case let .success(notifications, _):
self.validateNotifications(notifications)
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes) let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
if !notifications.isEmpty { if !notifications.isEmpty {
@ -143,6 +178,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(newNotifications, _): case let .success(newNotifications, _):
self.validateNotifications(newNotifications)
if !newNotifications.isEmpty { if !newNotifications.isEmpty {
self.older = .before(id: newNotifications.last!.id, count: nil) self.older = .before(id: newNotifications.last!.id, count: nil)
} }
@ -174,6 +210,7 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
completion(.failure(.client(error))) completion(.failure(.client(error)))
case let .success(newNotifications, _): case let .success(newNotifications, _):
self.validateNotifications(newNotifications)
guard !newNotifications.isEmpty else { guard !newNotifications.isEmpty else {
completion(.failure(.allCaughtUp)) completion(.failure(.allCaughtUp))
return return

View File

@ -147,7 +147,7 @@ extension OnboardingViewController: InstanceSelectorTableViewControllerDelegate
// no-op, don't show an error message // no-op, don't show an error message
} catch let error as Error { } catch let error as Error {
let alert = UIAlertController(title: "Error Logging In", message: error.localizedDescription, preferredStyle: .alert) 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) self.present(alert, animated: true)
} }
} }

View File

@ -11,6 +11,8 @@ import CoreData
struct AdvancedPrefsView : View { struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@State private var imageCacheSize: Int64 = 0
@State private var mastodonCacheSize: Int64 = 0
var body: some View { var body: some View {
List { List {
@ -64,13 +66,42 @@ struct AdvancedPrefsView : View {
} }
var cachingSection: some View { var cachingSection: some View {
Section(header: Text("Caching"), footer: Text("Clearing caches will restart the app.")) { Section {
Button(action: clearCache) { Button(action: clearCache) {
Text("Clear Mastodon Cache") Text("Clear Mastodon Cache")
}.foregroundColor(.red) }.foregroundColor(.red)
Button(action: clearImageCaches) { Button(action: clearImageCaches) {
Text("Clear Image Caches") Text("Clear Image Caches")
}.foregroundColor(.red) }.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) { Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline") 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? weak var owner: ProfileViewController?
let mastodonController: MastodonController let mastodonController: MastodonController
let filterer: Filterer
private(set) var accountID: String! private(set) var accountID: String!
let kind: Kind let kind: Kind
var initialHeaderMode: HeaderMode? var initialHeaderMode: HeaderMode?
@ -38,6 +39,9 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.kind = kind self.kind = kind
self.owner = owner self.owner = owner
self.mastodonController = owner.mastodonController 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) super.init(nibName: nil, bundle: nil)
@ -66,8 +70,11 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
if item.hideSeparators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} } else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
if case .status(_, _, _) = item { filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else if case .status(_, _, _, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
} }
@ -106,14 +113,20 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged)
}
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") 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.delegate = self
cell.showPinned = item.2 cell.showPinned = item.4
cell.updateUI(statusID: item.0, state: item.1) 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 return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
@ -139,8 +152,14 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.headerCell = cell self.headerCell = cell
return cell return cell
} }
case .status(id: let id, state: let state, pinned: let pinned): case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, 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: case .loadingIndicator:
return loadingIndicatorCell(for: indexPath) return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore: case .confirmLoadMore:
@ -225,7 +244,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
let existingPinned = snapshot.itemIdentifiers(inSection: .pinned) let existingPinned = snapshot.itemIdentifiers(inSection: .pinned)
let items = statuses.map { 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 // try to keep the existing status state
if let existing = existingPinned.first(where: { $0 == item }) { if let existing = existingPinned.first(where: { $0 == item }) {
return existing return existing
@ -238,6 +257,38 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
await apply(snapshot, animatingDifferences: true) 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() { @objc func refresh() {
guard case .loaded = state else { guard case .loaded = state else {
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
@ -288,19 +339,19 @@ extension ProfileStatusesViewController {
typealias TimelineItem = String typealias TimelineItem = String
case header(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 loadingIndicator
case confirmLoadMore case confirmLoadMore
static func fromTimelineItem(_ item: String) -> Self { 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 { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.header(a), .header(b)): case let (.header(a), .header(b)):
return a == 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 return a == b && ap == bp
case (.loadingIndicator, .loadingIndicator): case (.loadingIndicator, .loadingIndicator):
return true return true
@ -316,7 +367,7 @@ extension ProfileStatusesViewController {
case .header(let id): case .header(let id):
hasher.combine(0) hasher.combine(0)
hasher.combine(id) 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(1)
hasher.combine(id) hasher.combine(id)
hasher.combine(pinned) hasher.combine(pinned)
@ -338,7 +389,7 @@ extension ProfileStatusesViewController {
var isSelectable: Bool { var isSelectable: Bool {
switch self { switch self {
case .status(id: _, state: _, pinned: _): case .status(_, _, _, _):
return true return true
default: default:
return false return false
@ -445,11 +496,20 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
} }
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 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 return
} }
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)! let status = mastodonController.persistentContainer.status(for: id)!
selected(status: status.reblog?.id ?? id, state: state.copy()) selected(status: status.reblog?.id ?? id, state: collapseState.copy())
}
} }
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
@ -482,6 +542,17 @@ extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) 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 { 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. /// Types of results to search for. `nil` means all results will be included.
var resultTypes: [SearchResultType]? = nil var resultTypes: [SearchResultType]? = nil
/// Whether to limit results to accounts the users is following.
var following: Bool? = nil
let searchSubject = PassthroughSubject<String?, Never>() let searchSubject = PassthroughSubject<String?, Never>()
var currentQuery: String? var currentQuery: String?
@ -77,7 +79,6 @@ class SearchResultsViewController: EnhancedTableViewController {
tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1), tableView.trailingAnchor.constraint(equalToSystemSpacingAfter: errorLabel.trailingAnchor, multiplier: 1),
]) ])
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell) tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: accountCell)
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell) tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell) tableView.register(UINib(nibName: "HashtagTableViewCell", bundle: .main), forCellReuseIdentifier: hashtagCell)
@ -150,7 +151,7 @@ class SearchResultsViewController: EnhancedTableViewController {
activityIndicator.startAnimating() activityIndicator.startAnimating()
errorLabel.isHidden = true 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 mastodonController.run(request) { (response) in
switch response { switch response {
case let .success(results, _): case let .success(results, _):
@ -247,7 +248,7 @@ extension SearchResultsViewController {
enum Item: Hashable { enum Item: Hashable {
case account(String) case account(String)
case hashtag(Hashtag) case hashtag(Hashtag)
case status(String, StatusState) case status(String, CollapseState)
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {

View File

@ -270,7 +270,7 @@ extension SearchViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController) HashtagTimelineViewController(for: hashtag, mastodonController: self.mastodonController)
} actionProvider: { (_) in } 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): case let .link(card):

View File

@ -14,7 +14,7 @@ class StatusActionAccountListViewController: UIViewController {
private let mastodonController: MastodonController private let mastodonController: MastodonController
private let actionType: ActionType private let actionType: ActionType
private let statusID: String private let statusID: String
private let statusState: StatusState private let statusState: CollapseState
private var accountIDs: [String]? private var accountIDs: [String]?
/// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate. /// 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 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. - 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.mastodonController = mastodonController
self.actionType = actionType self.actionType = actionType
self.statusID = statusID self.statusID = statusID
@ -80,7 +80,7 @@ class StatusActionAccountListViewController: UIViewController {
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
cell.delegate = self 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 let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self cell.delegate = self
@ -120,6 +120,10 @@ class StatusActionAccountListViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
collectionView.indexPathsForSelectedItems?.forEach {
collectionView.deselectItem(at: $0, animated: true)
}
if accountIDs == nil { if accountIDs == nil {
Task { Task {
await loadAccounts() await loadAccounts()
@ -202,7 +206,7 @@ extension StatusActionAccountListViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController) ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in } 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) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion)
} }
} }
func statusCellShowFiltered(_ cell: StatusCollectionViewCell) {
fatalError()
}
} }
extension StatusActionAccountListViewController: StatusBarTappableViewController { extension StatusActionAccountListViewController: StatusBarTappableViewController {

View File

@ -14,18 +14,15 @@ class HashtagTimelineViewController: TimelineViewController {
let hashtag: Hashtag let hashtag: Hashtag
var toggleSaveButton: UIBarButtonItem! 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 { private var isHashtagSaved: Bool {
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name)) 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) { init(for hashtag: Hashtag, mastodonController: MastodonController) {
self.hashtag = hashtag self.hashtag = hashtag
@ -39,19 +36,16 @@ class HashtagTimelineViewController: TimelineViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
toggleSaveButton = UIBarButtonItem(title: toggleSaveButtonTitle, style: .plain, target: self, action: #selector(toggleSaveButtonPressed)) let menu = UIMenu(children: [
navigationItem.rightBarButtonItem = toggleSaveButton // uncached so that the saved/followed updates every time
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
NotificationCenter.default.addObserver(self, selector: #selector(savedHashtagsChanged), name: .savedHashtagsChanged, object: nil) elementHandler(actionsForHashtag(hashtag, source: .barButtonItem(self.navigationItem.rightBarButtonItem!)))
})
])
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu)
} }
@objc func savedHashtagsChanged() { private func toggleSave() {
toggleSaveButton.title = toggleSaveButtonTitle
}
// MARK: - Interaction
@objc func toggleSaveButtonPressed() {
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first { if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(existing) context.delete(existing)
@ -61,4 +55,10 @@ class HashtagTimelineViewController: TimelineViewController {
mastodonController.persistentContainer.save(context: context) 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 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.delegate = browsingEnabled ? self : nil
cell.overrideMastodonController = mastodonController 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) { override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {

View File

@ -10,9 +10,10 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, RefreshableViewController { class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
let timeline: Timeline let timeline: Timeline
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let filterer: Filterer
private(set) var controller: TimelineLikeController<TimelineItem>! private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>() let confirmLoadMore = PassthroughSubject<Void, Never>()
@ -28,6 +29,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
self.mastodonController = mastodonController 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) super.init(nibName: nil, bundle: nil)
@ -59,6 +70,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if item.hideSeparators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .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 { } else {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = 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) NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
} }
// separate method because InstanceTimelineViewController needs to be able to customize it // 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.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> { 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, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1) 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 let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
cell.showsIndicator = false cell.showsIndicator = false
@ -123,8 +149,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
case .status(id: let id, state: let state): case .status(id: let id, collapseState: let state, filterState: let filterState):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) 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: case .gap:
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ()) return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
case .loadingIndicator: case .loadingIndicator:
@ -224,14 +256,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
centerVisibleItem = allItems[centerVisible.row] centerVisibleItem = allItems[centerVisible.row]
} }
let ids = items.map { let ids = items.map {
if case .status(id: let id, state: _) = $0 { if case .status(id: let id, _, _) = $0 {
return id return id
} else { } else {
fatalError() fatalError()
} }
} }
let centerVisibleID: String let centerVisibleID: String
if case .status(id: let id, state: _) = centerVisibleItem { if case .status(id: let id, _, _) = centerVisibleItem {
centerVisibleID = id centerVisibleID = id
} else { } else {
fatalError() fatalError()
@ -252,8 +284,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
private func doRestore() -> Bool { private func doRestore() -> Bool {
guard let activity = activityToRestore, guard let activity = activityToRestore else {
let statusIDs = activity.userInfo?["statusIDs"] as? [String] else { return false
}
guard let statusIDs = activity.userInfo?["statusIDs"] as? [String] else {
stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs") stateRestorationLogger.fault("TimelineViewController: activity missing statusIDs")
return false return false
} }
@ -262,7 +296,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
controller.restoreInitial { controller.restoreInitial {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.appendSections([.statuses]) 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) snapshot.appendItems(items, toSection: .statuses)
dataSource.apply(snapshot, animatingDifferences: false) { dataSource.apply(snapshot, animatingDifferences: false) {
if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String, if let centerID = activity.userInfo?["centerID"] as? String ?? activity.userInfo?["topID"] as? String,
@ -297,6 +331,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false 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) { @objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
guard let scene = notification.object as? UIScene, guard let scene = notification.object as? UIScene,
// view.window is nil when the VC is not on screen // view.window is nil when the VC is not on screen
@ -312,16 +380,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
Task { Task {
if case .notLoadedInitial = controller.state { if case .notLoadedInitial = controller.state {
await controller.loadInitial() await controller.loadInitial()
} else {
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
if let presentItems {
insertPresentItemsIfNecessary(presentItems)
}
}
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
#endif #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 (loadNewerAndEndRefreshing(), try? loadInitial())
if let presentItems, !presentItems.isEmpty {
insertPresentItemsIfNecessary(presentItems)
}
}
} }
} }
@ -333,18 +409,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
private func insertPresentItemsIfNecessary(_ presentItems: [String]) { 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) let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, state: _) = currentItems.first, if case .status(id: let firstID, _, _) = 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 there's no overlap between presentItems and the existing items in the data source, prompt the user
!presentItems.contains(firstID) { !presentItems.contains(firstID) {
// remove any existing gap, if there is one // create a new snapshot to reset the timeline to the "present" state
if let index = currentItems.lastIndex(of: .gap) { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.deleteItems(Array(currentItems[index...])) snapshot.appendSections([.statuses])
} snapshot.appendItems(presentItems.map { .status(id: $0, collapseState: .unknown, filterState: .unknown) }, toSection: .statuses)
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
var config = ToastConfiguration(title: "Jump to present") var config = ToastConfiguration(title: "Jump to present")
config.edge = .top config.edge = .top
@ -353,12 +430,34 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.action = { [unowned self] toast in config.action = { [unowned self] toast in
toast.dismissToast(animated: true) 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) { 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) 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) 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 // NOTE: this only works when items are being inserted ABOVE the item to maintain
private func applySnapshot(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, maintainingBottomRelativeScrollPositionOf itemToMaintain: Item) { 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 var firstItemAfterOriginalGapOffsetFromTop: CGFloat = 0
if let indexPath = dataSource.indexPath(for: itemToMaintain), if let indexPath = dataSource.indexPath(for: itemToMaintain),
let cell = collectionView.cellForItem(at: indexPath) { 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 // 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 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) { 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 // 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 // firstItemAfterOriginalGapCell at the top of the screen and then scroll down by
// firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area // firstItemAfterOriginalGapOffsetFromTop without intruding into unmeasured area
var cur = indexPathOfItemAfterOriginalGap var cur = indexPathOfItemToMaintain
var amountScrolledUp: CGFloat = 0 var amountScrolledUp: CGFloat = 0
while true { while true {
if cur.row <= 0 { if cur.row <= 0 {
break break
} }
if let cell = self.collectionView.cellForItem(at: indexPathOfItemAfterOriginalGap), if let cell = self.collectionView.cellForItem(at: indexPathOfItemToMaintain),
cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > firstItemAfterOriginalGapOffsetFromTop { cell.convert(.zero, to: self.view).y - self.view.safeAreaInsets.top > offsetFromTop {
break break
} }
cur = IndexPath(row: cur.row - 1, section: cur.section) cur = IndexPath(row: cur.row - 1, section: cur.section)
@ -402,7 +504,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
amountScrolledUp += attrs.size.height amountScrolledUp += attrs.size.height
} }
self.collectionView.contentOffset.y += amountScrolledUp self.collectionView.contentOffset.y += amountScrolledUp
self.collectionView.contentOffset.y -= firstItemAfterOriginalGapOffsetFromTop self.collectionView.contentOffset.y -= offsetFromTop
} }
snapshotView.removeFromSuperview() snapshotView.removeFromSuperview()
@ -422,19 +524,19 @@ extension TimelineViewController {
enum Item: TimelineLikeCollectionViewItem { enum Item: TimelineLikeCollectionViewItem {
typealias TimelineItem = String // status ID typealias TimelineItem = String // status ID
case status(id: String, state: StatusState) case status(id: String, collapseState: CollapseState, filterState: FilterState)
case gap case gap
case loadingIndicator case loadingIndicator
case confirmLoadMore case confirmLoadMore
case publicTimelineDescription case publicTimelineDescription
static func fromTimelineItem(_ id: String) -> Self { 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 { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.status(id: a, state: _), .status(id: b, state: _)): case let (.status(id: a, _, _), .status(id: b, _, _)):
return a == b return a == b
case (.gap, .gap): case (.gap, .gap):
return true return true
@ -451,7 +553,7 @@ extension TimelineViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case .status(id: let id, state: _): case .status(id: let id, _, _):
hasher.combine(0) hasher.combine(0)
hasher.combine(id) hasher.combine(id)
case .gap: case .gap:
@ -476,7 +578,7 @@ extension TimelineViewController {
var isSelectable: Bool { var isSelectable: Bool {
switch self { switch self {
case .publicTimelineDescription, .gap, .status(id: _, state: _): case .publicTimelineDescription, .gap, .status(_, _, _):
return true return true
default: default:
return false return false
@ -506,7 +608,7 @@ extension TimelineViewController {
func loadNewer() async throws -> [TimelineItem] { func loadNewer() async throws -> [TimelineItem] {
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)! 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 throw Error.noNewer
} }
let newer = RequestRange.after(id: id, count: nil) let newer = RequestRange.after(id: id, count: nil)
@ -530,7 +632,7 @@ extension TimelineViewController {
func loadOlder() async throws -> [TimelineItem] { func loadOlder() async throws -> [TimelineItem] {
let snapshot = dataSource.snapshot() let snapshot = dataSource.snapshot()
let statusesSection = snapshot.indexOfSection(.statuses)! 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 throw Error.noNewer
} }
let older = RequestRange.before(id: id, count: nil) let older = RequestRange.before(id: id, count: nil)
@ -560,14 +662,14 @@ extension TimelineViewController {
switch direction { switch direction {
case .above: case .above:
guard gapIndexPath.row > 0, 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 // not really the right error but w/e
throw Error.noGap throw Error.noGap
} }
range = .before(id: id, count: nil) range = .before(id: id, count: nil)
case .below: case .below:
guard gapIndexPath.row < statusItemsCount - 1, 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 throw Error.noGap
} }
range = .after(id: id, count: nil) 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 // if there is any overlap, the first overlapping item will be the first item below the gap
var indexOfFirstTimelineItemExistingBelowGap: Int? 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) indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
} }
// the end index of the range of timelineItems that don't yet exist in the data source // the end index of the range of timelineItems that don't yet exist in the data source
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex 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 { if toInsert.isEmpty {
addedItems = false addedItems = false
} else { } else {
@ -645,7 +747,7 @@ extension TimelineViewController {
// if there's any overlap, last overlapping item will be the last item below the gap // if there's any overlap, last overlapping item will be the last item below the gap
var indexOfLastTimelineItemExistingAboveGap: Int? 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) indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
} }
@ -657,7 +759,7 @@ extension TimelineViewController {
} else { } else {
startIndex = timelineItems.startIndex 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 { if toInsert.isEmpty {
addedItems = false addedItems = false
} else { } else {
@ -721,10 +823,18 @@ extension TimelineViewController: UICollectionViewDelegate {
switch item { switch item {
case .publicTimelineDescription: case .publicTimelineDescription:
removeTimelineDescriptionCell() removeTimelineDescriptionCell()
case .status(id: let id, state: let state): 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)! 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 // 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()) selected(status: status.reblog?.id ?? id, state: collapseState.copy())
}
case .gap: case .gap:
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true cell.showsIndicator = true
@ -773,6 +883,17 @@ extension TimelineViewController: StatusCollectionViewCellDelegate {
dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) 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 { extension TimelineViewController: TabBarScrollableViewController {

View File

@ -7,6 +7,7 @@
// //
import UIKit import UIKit
import SwiftUI
class TimelinesPageViewController: SegmentedPageViewController { class TimelinesPageViewController: SegmentedPageViewController {
@ -40,28 +41,42 @@ class TimelinesPageViewController: SegmentedPageViewController {
title = homeTitle title = homeTitle
tabBarItem.image = UIImage(systemName: "house.fill") 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) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
func stateRestorationActivity() -> NSUserActivity? {
return (pageControllers[currentIndex] as! TimelineViewController).stateRestorationActivity()
}
func restoreActivity(_ activity: NSUserActivity) { func restoreActivity(_ activity: NSUserActivity) {
guard let timeline = UserActivityManager.getTimeline(from: activity) else { guard let timeline = UserActivityManager.getTimeline(from: activity) else {
return return
} }
let index: Int
switch timeline { switch timeline {
case .home: case .home:
selectPage(at: 0, animated: false) index = 0
case .public(local: false): case .public(local: false):
selectPage(at: 1, animated: false) index = 1
case .public(local: true): case .public(local: true):
selectPage(at: 2, animated: false) index = 2
default: default:
return return
} }
let timelineVC = pageControllers[currentIndex] as! TimelineViewController selectPage(at: index, animated: false)
let timelineVC = pageControllers[index] as! TimelineViewController
timelineVC.restoreActivity(activity) 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 } private var mastodonController: MastodonController? { navigationDelegate?.apiController }
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] { func actionsForProfile(accountID: String, source: PopoverSource) -> [UIMenuElement] {
guard let mastodonController = mastodonController, guard let mastodonController = mastodonController,
let account = mastodonController.persistentContainer.account(for: accountID) else { return [] } let account = mastodonController.persistentContainer.account(for: accountID) else { return [] }
var shareSection = [ var shareSection = [
openInSafariAction(url: account.url), 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 } 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 [ return [
openInSafariAction(url: url), 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 } 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] { func actionsForHashtag(_ hashtag: Hashtag, source: PopoverSource) -> [UIMenuElement] {
let actionsSection: [UIMenuElement] var actionsSection: [UIMenuElement] = []
if let mastodonController = mastodonController, if let mastodonController = mastodonController,
mastodonController.loggedIn { mastodonController.loggedIn {
let name = hashtag.name.lowercased()
let context = mastodonController.persistentContainer.viewContext 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 = [ 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 { if let existing = existing {
context.delete(existing) context.delete(existing)
} else { } else {
@ -121,13 +124,21 @@ extension MenuActionProvider {
mastodonController.persistentContainer.save(context: context) mastodonController.persistentContainer.save(context: context)
}) })
] ]
} else { if mastodonController.instanceFeatures.canFollowHashtags {
actionsSection = [] 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] let shareSection: [UIMenuElement]
if let url = URL(hashtag.url) { if let url = URL(hashtag.url) {
shareSection = actionsForURL(url, sourceView: sourceView) shareSection = actionsForURL(url, source: source)
} else { } else {
shareSection = [] 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 mastodonController = mastodonController else { return [] }
guard let accountID = mastodonController.accountInfo?.id else { guard let accountID = mastodonController.accountInfo?.id else {
// user is logged out // user is logged out
return [ return [
openInSafariAction(url: status.url!), 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 } 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 { } else {
Logging.general.fault("Status missing URL: id=\(status.id, privacy: .public), reblog=\((status.reblog?.id).debugDescription, privacy: .public)") 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 } 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)) 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 { private func relationshipAction(accountID: String, mastodonController: MastodonController, builder: @escaping @MainActor (RelationshipMO, MastodonController) -> UIMenuElement) -> UIDeferredMenuElement {
return UIDeferredMenuElement.uncached({ @MainActor elementHandler in return UIDeferredMenuElement.uncached({ @MainActor elementHandler in
let relationship = Task { let relationship = Task {
@ -380,14 +402,26 @@ extension MenuActionProvider {
private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement { private func followAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement {
let accountID = relationship.accountID let accountID = relationship.accountID
let following = relationship.following let following = relationship.following
return createAction(identifier: "follow", title: following ? "Unfollow" : "Follow", systemImageName: following ? "person.badge.minus" : "person.badge.plus") { [weak self] _ in let requested = relationship.requested
let request = (following ? Account.unfollow : Account.follow)(accountID) 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 mastodonController.run(request) { response in
switch response { switch response {
case .failure(let error): 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, _): case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: 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") self?.handleError(error, title: "Error \(block ? "B" : "Unb")locking")
case .success(let relationship, _): case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship) mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: "\(block ? "B" : "Unb")locked")
} }
} }
} }
@ -415,8 +450,11 @@ extension MenuActionProvider {
return { [weak self] (_: UIAction) in return { [weak self] (_: UIAction) in
let req = block ? Client.block(domain: host) : Client.unblock(domain: host) let req = block ? Client.block(domain: host) : Client.unblock(domain: host)
mastodonController.run(req) { response in 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") 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") self?.handleError(error, title: "Error Unmuting")
case .success(let relationship, _): case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship) mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
self?.handleSuccess(title: "Unmuted")
} }
} }
} }

View File

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

View File

@ -56,7 +56,7 @@ class SplitNavigationController: UIViewController {
let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow { let selectedIndexPath = tableVC.tableView.indexPathForSelectedRow {
tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true) tableVC.tableView.deselectRow(at: selectedIndexPath, animated: true)
} else if let sender = sender as? UIViewController, } 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 // the collection view's animation speed is weirdly fast, so we do it slower
UIView.animate(withDuration: 0.5, delay: 0) { UIView.animate(withDuration: 0.5, delay: 0) {
collectionView.indexPathsForSelectedItems?.forEach { collectionView.deselectItem(at: $0, animated: false) } 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 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? { static func getDraft(from activity: NSUserActivity) -> Draft? {
guard activity.activityType == UserActivityType.newPost.rawValue, let idStr: String?
let str = activity.userInfo?["draftID"] as? String, if activity.activityType == UserActivityType.newPost.rawValue {
let uuid = UUID(uuidString: str) else { 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 nil
} }
return DraftsManager.shared.getBy(id: uuid) return DraftsManager.shared.getBy(id: uuid)

View File

@ -13,7 +13,7 @@ import Pachyderm
protocol TuskerNavigationDelegate: UIViewController, ToastableViewController { protocol TuskerNavigationDelegate: UIViewController, ToastableViewController {
var apiController: MastodonController! { get } var apiController: MastodonController! { get }
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController func conversation(mainStatusID: String, state: CollapseState) -> ConversationTableViewController
} }
extension TuskerNavigationDelegate { extension TuskerNavigationDelegate {
@ -59,7 +59,7 @@ extension TuskerNavigationDelegate {
message += " This can happen if you do not have an app installed for '\(scheme)://' URLs." 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) 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) 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) return ConversationTableViewController(for: mainStatusID, state: state, mastodonController: apiController)
} }
@ -84,11 +84,11 @@ extension TuskerNavigationDelegate {
self.selected(status: statusID, state: .unknown) 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) 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 { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id) let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions() let options = UIWindowScene.ActivationRequestOptions()
@ -97,20 +97,20 @@ extension TuskerNavigationDelegate {
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
if #available(iOS 16.0, *), if #available(iOS 16.0, *),
presentDuckable(compose) { presentDuckable(compose, animated: animated) {
return return
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
let nav = UINavigationController(rootViewController: compose) let nav = UINavigationController(rootViewController: compose)
nav.presentationController?.delegate = 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) 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 { 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) 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) let vc = moreOptions(forStatus: statusID)
vc.popoverPresentationController?.sourceView = sourceView source.apply(to: vc)
present(vc, animated: true) present(vc, animated: true)
} }
func showMoreOptions(forURL url: URL, sourceView: UIView?) { func showMoreOptions(forURL url: URL, source: PopoverSource) {
let vc = moreOptions(forURL: url) let vc = moreOptions(forURL: url)
vc.popoverPresentationController?.sourceView = sourceView source.apply(to: vc)
present(vc, animated: true) present(vc, animated: true)
} }
func showMoreOptions(forAccount accountID: String, sourceView: UIView?) { func showMoreOptions(forAccount accountID: String, source: PopoverSource) {
let vc = moreOptions(forAccount: accountID) let vc = moreOptions(forAccount: accountID)
vc.popoverPresentationController?.sourceView = sourceView source.apply(to: vc)
present(vc, animated: true) present(vc, animated: true)
} }
@ -183,8 +183,35 @@ extension TuskerNavigationDelegate {
show(vc, sender: self) 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) 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 } guard let mastodonController = mastodonController else { return nil }
return ( return (
content: { ProfileViewController(accountID: self.accountID, mastodonController: mastodonController) }, 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 import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
private let dataDetectorsScheme = "x-apple-data-detectors"
class ContentTextView: LinkTextView, BaseEmojiLabel { class ContentTextView: LinkTextView, BaseEmojiLabel {
@ -21,14 +22,19 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
weak var overrideMastodonController: MastodonController? weak var overrideMastodonController: MastodonController?
var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController } var mastodonController: MastodonController? { overrideMastodonController ?? navigationDelegate?.apiController }
var defaultFont: UIFont = .systemFont(ofSize: 17) private var htmlConverter = HTMLConverter()
var defaultColor: UIColor = .label var defaultFont: UIFont {
var paragraphStyle: NSParagraphStyle = { _read { yield htmlConverter.font }
let style = NSMutableParagraphStyle() _modify { yield &htmlConverter.font }
// 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 var defaultColor: UIColor {
return style _read { yield htmlConverter.color }
}() _modify { yield &htmlConverter.color }
}
var paragraphStyle: NSParagraphStyle {
_read { yield htmlConverter.paragraphStyle }
_modify { yield &htmlConverter.paragraphStyle }
}
private(set) var hasEmojis = false private(set) var hasEmojis = false
@ -84,99 +90,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// MARK: - HTML Parsing // MARK: - HTML Parsing
func setTextFromHtml(_ html: String) { func setTextFromHtml(_ html: String) {
let doc = try! SwiftSoup.parse(html) self.attributedText = htmlConverter.convert(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))")
}
} }
// MARK: - Interaction // MARK: - Interaction
@ -198,7 +112,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
} }
let location = recognizer.location(in: self) 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) let text = (self.text as NSString).substring(with: range)
handleLinkTapped(url: link, text: text) handleLinkTapped(url: link, text: text)
} }
@ -287,9 +202,15 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
extension ContentTextView: UITextViewDelegate { extension ContentTextView: UITextViewDelegate {
func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { 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 // 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 text = (self.text as NSString).substring(with: range)
let actions: [UIMenuElement] let actions: [UIMenuElement]
if let mention = self.getMention(for: link, text: text) { 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) { } else if let tag = self.getHashtag(for: link, text: text) {
actions = self.actionsForHashtag(tag, sourceView: self) actions = self.actionsForHashtag(tag, source: .view(self))
} else { } else {
actions = self.actionsForURL(link, sourceView: self) actions = self.actionsForURL(link, source: .view(self))
} }
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
} }

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import SwiftSoup import SwiftSoup
import Sentry
class ActionNotificationGroupTableViewCell: UITableViewCell { class ActionNotificationGroupTableViewCell: UITableViewCell {
@ -66,7 +67,17 @@ class ActionNotificationGroupTableViewCell: UITableViewCell {
self.group = group self.group = group
guard let firstNotification = group.notifications.first else { fatalError() } 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 self.statusID = status.id
updateUIForPreferences() updateUIForPreferences()

View File

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

View File

@ -52,7 +52,7 @@ class PollFinishedTableViewCell: UITableViewCell {
displayNameLabel.text = notification.account.displayName displayNameLabel.text = notification.account.displayName
displayNameLabel.setEmojis(notification.account.emojis, identifier: notification.account.id) 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() statusContentLabel.text = try! doc.text()
pollView.updateUI(status: status, poll: poll) pollView.updateUI(status: status, poll: poll)
@ -114,7 +114,7 @@ extension PollFinishedTableViewCell: MenuPreviewProvider {
return (content: { return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown) delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: { }, 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 moreButton: VisualEffectImageButton!
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel! @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 noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var fieldsView: ProfileFieldsView!
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl! @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.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light))
usernameLabel.adjustsFontForContentSizeCategory = true usernameLabel.adjustsFontForContentSizeCategory = true
followsYouLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14)) relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
followsYouLabel.adjustsFontForContentSizeCategory = true relationshipLabel.adjustsFontForContentSizeCategory = true
noteTextView.defaultFont = .preferredFont(forTextStyle: .body) noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.adjustsFontForContentSizeCategory = true noteTextView.adjustsFontForContentSizeCategory = true
@ -117,10 +118,11 @@ class ProfileHeaderView: UIView {
updateUIForPreferences() updateUIForPreferences()
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked
updateImages(account: account) 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.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
@ -148,6 +150,7 @@ class ProfileHeaderView: UIView {
accessibilityElements = [ accessibilityElements = [
displayNameLabel!, displayNameLabel!,
usernameLabel!, usernameLabel!,
relationshipLabel!,
noteTextView!, noteTextView!,
fieldsView!, fieldsView!,
moreButton!, moreButton!,
@ -161,7 +164,22 @@ class ProfileHeaderView: UIView {
return 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() { @objc private func updateUIForPreferences() {

View File

@ -40,7 +40,7 @@
</constraints> </constraints>
</view> </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"> <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"/> <fontDescription key="fontDescription" type="system" weight="semibold" pointSize="24"/>
<nil key="textColor"/> <nil key="textColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
@ -60,7 +60,7 @@
</userDefinedRuntimeAttributes> </userDefinedRuntimeAttributes>
</view> </view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="top" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="u4P-3i-gEq"> <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> <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"> <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"/> <rect key="frame" x="0.0" y="0.0" width="75.5" height="0.0"/>
@ -83,7 +83,7 @@
</constraints> </constraints>
</view> </view>
<segmentedControl opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="top" segmentControlStyle="plain" selectedSegmentIndex="0" translatesAutoresizingMaskIntoConstraints="NO" id="n1M-vM-Cj0"> <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> <segments>
<segment title="Posts"/> <segment title="Posts"/>
<segment title="Posts and Replies"/> <segment title="Posts and Replies"/>
@ -94,7 +94,7 @@
</connections> </connections>
</segmentedControl> </segmentedControl>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5ja-fK-Fqz"> <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"/> <color key="backgroundColor" systemColor="separatorColor"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="0.5" id="VwS-gV-q8M"/> <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"/> <constraint firstItem="1O8-2P-Gbf" firstAttribute="width" secondItem="u4P-3i-gEq" secondAttribute="width" constant="-16" id="hnA-3G-B9B"/>
</constraints> </constraints>
</stackView> </stackView>
<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"> <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"/> <rect key="frame" x="0.0" y="2.5" width="81" height="18"/>
<fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/> <fontDescription key="fontDescription" type="system" weight="light" pointSize="15"/>
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </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> </subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/> <viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/> <color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints> <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="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="wT9-2J-uSY" firstAttribute="centerY" secondItem="dgG-dR-lSv" secondAttribute="bottom" id="7gb-T3-Xe7"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/> <constraint firstItem="vcl-Gl-kXl" firstAttribute="top" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="8" symbolic="YES" id="7ss-Mf-YYH"/>
<constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/> <constraint firstItem="vcl-Gl-kXl" firstAttribute="leading" secondItem="wT9-2J-uSY" secondAttribute="trailing" constant="8" id="8ho-WU-MxW"/>
<constraint firstItem="jwU-EH-hmC" firstAttribute="top" secondItem="vcl-Gl-kXl" secondAttribute="bottom" id="9lx-Fn-M0U"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="u4P-3i-gEq" secondAttribute="bottom" id="9zc-N2-mfI"/>
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/> <constraint firstItem="bRJ-Xf-kc9" firstAttribute="bottom" secondItem="dgG-dR-lSv" secondAttribute="bottom" constant="-8" id="AXS-bG-20Q"/>
<constraint 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="dgG-dR-lSv" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="VD1-yc-KSa"/>
<constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/> <constraint firstItem="wT9-2J-uSY" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="WNS-AR-ff2"/>
<constraint firstItem="bRJ-Xf-kc9" firstAttribute="trailing" secondItem="vUN-kp-3ea" secondAttribute="trailing" constant="-8" id="ZB4-ys-9zP"/> <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="vUN-kp-3ea" firstAttribute="trailing" secondItem="vcl-Gl-kXl" secondAttribute="trailing" constant="16" id="e38-Od-kPg"/>
<constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/> <constraint firstItem="u4P-3i-gEq" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="hgl-UR-o3W"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/> <constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="dgG-dR-lSv" secondAttribute="trailing" id="j0d-hY-815"/>
<constraint firstItem="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 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" secondItem="wT9-2J-uSY" secondAttribute="bottom" priority="999" 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="jwU-EH-hmC" secondAttribute="bottom" priority="999" constant="8" id="xDD-rx-gC0"/>
</constraints> </constraints>
<connections> <connections>
<outlet property="avatarContainerView" destination="wT9-2J-uSY" id="yEm-h7-tfq"/> <outlet property="avatarContainerView" destination="wT9-2J-uSY" id="yEm-h7-tfq"/>
<outlet property="avatarImageView" destination="TkY-oK-if4" id="bSJ-7z-j4w"/> <outlet property="avatarImageView" destination="TkY-oK-if4" id="bSJ-7z-j4w"/>
<outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/> <outlet property="displayNameLabel" destination="vcl-Gl-kXl" id="64n-a9-my0"/>
<outlet property="fieldsView" destination="vKC-m1-Sbs" id="FeE-jh-lYH"/> <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="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="moreButton" destination="bRJ-Xf-kc9" id="zIN-pz-L7y"/>
<outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/> <outlet property="noteTextView" destination="1O8-2P-Gbf" id="yss-zZ-uQ5"/>
<outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/> <outlet property="pagesSegmentedControl" destination="n1M-vM-Cj0" id="TCU-ku-YZN"/>
<outlet property="relationshipLabel" destination="UF8-nI-KVj" id="dTe-DQ-eJV"/>
<outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/> <outlet property="usernameLabel" destination="1C3-Pd-QiL" id="57b-LQ-3pM"/>
</connections> </connections>
<point key="canvasLocation" x="-590" y="117"/> <point key="canvasLocation" x="-590" y="117"/>
@ -155,6 +166,7 @@
</objects> </objects>
<resources> <resources>
<image name="ellipsis" catalog="system" width="128" height="37"/> <image name="ellipsis" catalog="system" width="128" height="37"/>
<image name="lock.fill" catalog="system" width="125" height="128"/>
<systemColor name="labelColor"> <systemColor name="labelColor">
<color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor> </systemColor>

View File

@ -57,7 +57,7 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
} }
private(set) var statusState: StatusState! private(set) var statusState: CollapseState!
var collapsible = false { var collapsible = false {
didSet { didSet {
collapseButton.isHidden = !collapsible 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() createObserversIfNecessary()
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
@ -142,7 +142,7 @@ class BaseStatusTableViewCell: UITableViewCell {
doUpdateUI(status: status, state: state) doUpdateUI(status: status, state: state)
} }
func doUpdateUI(status: StatusMO, state: StatusState) { func doUpdateUI(status: StatusMO, state: CollapseState) {
self.statusState = state self.statusState = state
let account = status.account let account = status.account
@ -182,8 +182,8 @@ class BaseStatusTableViewCell: UITableViewCell {
updateStatusIconsForPreferences(status) updateStatusIconsForPreferences(status)
if state.unknown { if state.unknown {
layoutIfNeeded() // 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: contentTextView.bounds.height) state.resolveFor(status: status, height: 0, textLength: contentTextView.attributedText.length)
if state.collapsible! && showStatusAutomatically { if state.collapsible! && showStatusAutomatically {
state.collapsed = false state.collapsed = false
} }
@ -209,7 +209,7 @@ class BaseStatusTableViewCell: UITableViewCell {
// keep menu in sync with changed states e.g. bookmarked, muted // 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 // 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.isHidden = status.poll == nil
pollView.mastodonController = mastodonController pollView.mastodonController = mastodonController
@ -336,7 +336,6 @@ class BaseStatusTableViewCell: UITableViewCell {
super.prepareForReuse() super.prepareForReuse()
avatarRequest?.cancel() avatarRequest?.cancel()
attachmentsView.attachmentViews.allObjects.forEach { $0.removeFromSuperview() }
showStatusAutomatically = false showStatusAutomatically = false
} }
@ -409,7 +408,7 @@ class BaseStatusTableViewCell: UITableViewCell {
} }
@IBAction func morePressed() { @IBAction func morePressed() {
delegate?.showMoreOptions(forStatus: statusID, sourceView: moreButton) delegate?.showMoreOptions(forStatus: statusID, source: .view(moreButton))
} }
@objc func accountPressed() { @objc func accountPressed() {

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