Compare commits

...

40 Commits

Author SHA1 Message Date
Shadowfacts 0f6e9c97cc Bump build number and update changelog 2022-05-15 17:40:01 -04:00
Shadowfacts 98516e3802 Fix multiple lines of emojis (e.g., wordle) getting smushed together 2022-05-15 15:42:48 -04:00
Shadowfacts 68b03838a2 Fix saved hashtags sorting being case-sensitive 2022-05-15 10:37:38 -04:00
Shadowfacts 1f0025b101 Fix Send Message action not working on iPad/Mac 2022-05-15 10:34:39 -04:00
Shadowfacts b46f007f64 Fix Cmd+N shortcut for Compose not working on Mac (Catalyst or Designed
for iPad)
2022-05-15 10:34:24 -04:00
Shadowfacts ecab33bdce Better generics for LazilyDecoding 2022-05-13 17:33:07 -04:00
Shadowfacts cc0da2ec54 Fix user activities not continuing when passed at launch
Fix crash when continuing user activities on iPad
2022-05-13 17:10:18 -04:00
Shadowfacts a2868739c2 Fix crash when poll voting fails 2022-05-13 10:00:11 -04:00
Shadowfacts 2f75510889 Disable transparent nav bar in conversation vc 2022-05-11 19:15:56 -04:00
Shadowfacts 46332cd1b9 Jump to statuses below parent when expanding subthread in conversation 2022-05-11 19:12:28 -04:00
Shadowfacts 21e9ca990d Use async/await for conversation loading 2022-05-11 19:10:38 -04:00
Shadowfacts 1a02319894 Fix using old style for show all statuses bar button item when showing a
conversation that initially expands all statuses
2022-05-11 11:33:18 -04:00
Shadowfacts 4a95ccccdb Show expand thread indicator when there are additional replies to an
intermediate post in thread authored by a single person
2022-05-11 11:20:01 -04:00
Shadowfacts d3187ce2c4 Move saved instances and hashtags to CoreData 2022-05-10 22:58:30 -04:00
Shadowfacts ed0643c4ad Change explore swipe action titles 2022-05-10 22:58:30 -04:00
Shadowfacts 1e2947ceba Fix crash when accept/reject follow request fails 2022-05-10 22:58:30 -04:00
Shadowfacts ddcb13dd28 Fix notifications sometimes getting deleted in group merging
Closes #156
2022-05-10 22:58:25 -04:00
Shadowfacts c71bf3ba23 Fix displaying toasts from non-main queue 2022-05-09 15:55:35 -04:00
Shadowfacts 3e5c441b24 Fix crash when refreshing polls 2022-05-09 15:54:27 -04:00
Shadowfacts 0b6c16b0a6 Fix newly created statuses/accounts not having lastFetchedAt set
awakeFromFetch is only called on existing objects
2022-05-06 10:24:50 -04:00
Shadowfacts 5f566724bb Fix compose CW field overflowing 2022-05-03 20:14:55 -04:00
Shadowfacts 4a89ae3cfe Don't cache state of follow menu action
Fixes #151
2022-05-02 17:59:03 -04:00
Shadowfacts 56a0518c80 Add toast error messages to menu actions 2022-05-01 23:06:59 -04:00
Shadowfacts bf8a294676 Split MenuActionProvider from MenuPreviewProvider 2022-05-01 23:05:23 -04:00
Shadowfacts c069712c22 Don't include Open in Tusker on Catalyst 2022-05-01 21:50:16 -04:00
Shadowfacts d04957ba41 Remove reference counting system
Delete statuses/accounts that haven't been fetched in a week
2022-05-01 21:50:16 -04:00
Shadowfacts 8cc08cf4c0 Fix crash when displaying polls on Catalyst in Optimize for Mac
Closes #152
2022-05-01 21:50:11 -04:00
Shadowfacts 1b917f6bed Fix saved hashtags not getting persisted 2022-05-01 12:05:38 -04:00
Shadowfacts 514e569bd5 Fast account switching on iPad 2022-05-01 11:53:12 -04:00
Shadowfacts a22059a1a1 Show current user avatar in sidebar 2022-04-30 13:05:20 -04:00
Shadowfacts 2cfefc9432 Add "Add Account" placeholder to fast account switcher 2022-04-30 11:46:14 -04:00
Shadowfacts 2f7c7bae5e Extract status posting to separate class, convert to async/await 2022-04-30 11:11:22 -04:00
Shadowfacts 3f04d74dd6 Better error messages when exporting video fails 2022-04-27 23:33:29 -04:00
Shadowfacts 4dd8c1d692 Add subtitles to visibility context menu items
Closes #155
2022-04-27 23:21:08 -04:00
Shadowfacts eb9a5aeb42 Perform grouping with existing notifications when refreshing
Closes #88
2022-04-26 22:57:46 -04:00
Shadowfacts 7465abe0a9 Fix crash when loading account 2022-04-26 22:11:19 -04:00
Shadowfacts 20dab7c77a Handle missing account emojis on pixelfed instances 2022-04-26 17:50:23 -04:00
Shadowfacts 4e105e0fbc Fix table view cell gesture blocking toast long-press
Fixes #149
2022-04-26 13:29:22 -04:00
Shadowfacts d2f1d78aa2 Fix crash when preferences are changed before own account is loaded 2022-04-25 18:53:51 -04:00
Shadowfacts 360f52d0cf Don't crash when saving persistent store fails 2022-04-25 18:51:16 -04:00
86 changed files with 2029 additions and 773 deletions

View File

@ -1,5 +1,33 @@
# Changelog # Changelog
## 2022.1 (30)
Features/Improvements:
- Add fast account switching on iPad
- Add "Add Account" option to fast account switcher
- Show "# more replies" indicator in conversation in more circumstances
- When refreshing notifications, new ones are grouped with existing notifications
- Add subtitles to explain post visibility options
- Improve error messages when posting a video fails
- Display error messages instead of crashing when certain actions fail
Bugfixes:
- Fix Shortcuts actions not working in some circumstances
- Fix CW field growing wider than the screen
- Fix saved hashtags not persisting
- Fix not being able to long-press error-message bubbles
- Fix follow context menu item not updating after following
- Fix not being able to log in to certain Pixelfed instances
- Fix crash when closing the app
- Fix crash when loading profile screen
- Fix crash when refreshing polls
- Fix crash when poll voting fails
- Fix crash when accepting/rejecting a follow request fails
- Fix saved hashtags being sorted with case-sensitivity
- Fix multiple lines of text with emojis getting squashed together
- iPad: Fix Shortcuts actions showing wrong window type
- Mac: Fix Cmd+N shortcut not opening Compose window
- iPad/Mac: Fix Send Message action not mentioning account
## 2022.1 (27) ## 2022.1 (27)
Features/Improvements: Features/Improvements:
- Add emoji picker button to Compose screen toolbar - Add emoji picker button to Compose screen toolbar

View File

@ -49,7 +49,12 @@ public final class Account: AccountProtocol, Decodable {
self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic) self.avatarStatic = try? container.decode(URL.self, forKey: .avatarStatic)
self.header = try? container.decode(URL.self, forKey: .header) self.header = try? container.decode(URL.self, forKey: .header)
self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic) self.headerStatic = try? container.decode(URL.self, forKey: .headerStatic)
self.emojis = try container.decode([Emoji].self, forKey: .emojis) // even up-to-date pixelfed instances sometimes lack this, for reasons unclear
if let emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) {
self.emojis = emojis
} else {
self.emojis = []
}
self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? [] self.fields = (try? container.decode([Field].self, forKey: .fields)) ?? []
self.bot = try? container.decode(Bool.self, forKey: .bot) self.bot = try? container.decode(Bool.self, forKey: .bot)

View File

@ -34,6 +34,13 @@ public class Hashtag: Codable {
self.history = try container.decodeIfPresent([History].self, forKey: .history) self.history = try container.decodeIfPresent([History].self, forKey: .history)
} }
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(url.absoluteString, forKey: .url)
try container.encodeIfPresent(history, forKey: .history)
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name case name
case url case url

View File

@ -8,8 +8,8 @@
import Foundation import Foundation
public class NotificationGroup: Identifiable, Hashable { public struct NotificationGroup: Identifiable, Hashable {
public let 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: StatusState?
@ -27,34 +27,91 @@ public class NotificationGroup: Identifiable, Hashable {
} }
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool { public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
return lhs.id == rhs.id guard lhs.notifications.count == rhs.notifications.count else {
return false
}
for (a, b) in zip(lhs.notifications, rhs.notifications) where a.id != b.id {
return false
}
return true
} }
public func hash(into hasher: inout Hasher) { public func hash(into hasher: inout Hasher) {
hasher.combine(id) for notification in notifications {
hasher.combine(notification.id)
}
}
private mutating func append(_ notification: Notification) {
notifications.append(notification)
}
private mutating func append(group: NotificationGroup) {
notifications.append(contentsOf: group.notifications)
} }
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] { public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [[Notification]]() var groups = [NotificationGroup]()
for notification in notifications { for notification in notifications {
if allowedTypes.contains(notification.kind) { if allowedTypes.contains(notification.kind) {
if let lastGroup = groups.last, let firstNotification = lastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id { if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
groups[groups.count - 1].append(notification) groups[groups.count - 1].append(notification)
continue continue
} else if groups.count >= 2 { } else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2] let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1][0].kind), let firstNotification = secondToLastGroup.first, firstNotification.kind == notification.kind, firstNotification.status?.id == notification.status?.id { if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
groups[groups.count - 2].append(notification) groups[groups.count - 2].append(notification)
continue continue
} }
} }
} }
groups.append([notification]) groups.append(NotificationGroup(notifications: [notification])!)
} }
return groups.map { return groups
NotificationGroup(notifications: $0)!
} }
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
}
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
guard !first.isEmpty else {
return second
}
guard !second.isEmpty else {
return first
}
var merged = first
var second = second
merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) {
second.removeFirst()
guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else {
merged.append(firstGroupFromSecond)
break
}
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
merged[merged.count - 1].append(group: firstGroupFromSecond)
} else if merged.count >= 2 {
let secondToLastGroup = merged[merged.count - 2]
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
merged[merged.count - 2].append(group: firstGroupFromSecond)
} else {
merged.append(firstGroupFromSecond)
}
} else {
merged.append(firstGroupFromSecond)
}
}
merged.append(contentsOf: second)
return merged
} }
} }

View File

@ -0,0 +1,268 @@
//
// NotificationGroupTests.swift
//
//
// Created by Shadowfacts on 4/26/22.
//
import XCTest
@testable import Pachyderm
class NotificationGroupTests: XCTestCase {
let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
let statusA = """
{
"id": "1",
"created_at": "2019-11-23T07:28:34Z",
"account": {
"id": "2",
"username": "bar",
"acct": "bar",
"display_name": "bar",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@bar",
"uri": "https://example.com/@bar",
},
"url": "https://example.com/@bar/1",
"uri": "https://example.com/@bar/1",
"content": "",
"emojis": [],
"reblogs_count": 0,
"favourites_count": 0,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"media_attachments": [],
"mentions": [],
"tags": [],
}
}
"""
lazy var likeA1Data = """
{
"id": "1",
"type": "favourite",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "1",
"username": "foo",
"acct": "foo",
"display_name": "foo",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@foo",
"uri": "https://example.com/@foo",
},
"status": \(statusA)
""".data(using: .utf8)!
lazy var likeA1 = try! decoder.decode(Notification.self, from: likeA1Data)
lazy var likeA2Data = """
{
"id": "2",
"type": "favourite",
"created_at": "2019-11-23T07:30:00Z",
"account": {
"id": "2",
"username": "baz",
"acct": "baz",
"display_name": "baz",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@baz",
"uri": "https://example.com/@baz",
},
"status": \(statusA)
""".data(using: .utf8)!
lazy var likeA2 = try! decoder.decode(Notification.self, from: likeA2Data)
let statusB = """
{
"id": "2",
"created_at": "2019-11-23T07:28:34Z",
"account": {
"id": "2",
"username": "bar",
"acct": "bar",
"display_name": "bar",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@bar",
"uri": "https://example.com/@bar",
},
"url": "https://example.com/@bar/1",
"uri": "https://example.com/@bar/1",
"content": "",
"emojis": [],
"reblogs_count": 0,
"favourites_count": 0,
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"media_attachments": [],
"mentions": [],
"tags": [],
}
}
"""
lazy var likeBData = """
{
"id": "3",
"type": "favourite",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "1",
"username": "foo",
"acct": "foo",
"display_name": "foo",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@foo",
"uri": "https://example.com/@foo",
},
"status": \(statusB)
""".data(using: .utf8)!
lazy var likeB = try! decoder.decode(Notification.self, from: likeBData)
lazy var likeB2Data = """
{
"id": "4",
"type": "favourite",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "2",
"username": "bar",
"acct": "bar",
"display_name": "bar",
"locked": false,
"created_at": "2019-11-02T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@bar",
"uri": "https://example.com/@bar",
},
"status": \(statusB)
""".data(using: .utf8)!
lazy var likeB2 = try! decoder.decode(Notification.self, from: likeB2Data)
lazy var mentionBData = """
{
"id": "5",
"type": "mention",
"created_at": "2019-11-23T07:29:18Z",
"account": {
"id": "1",
"username": "foo",
"acct": "foo",
"display_name": "foo",
"locked": false,
"created_at": "2019-11-01T01:01:01Z",
"followers_count": 0,
"following_count": 0,
"statuses_count": 0,
"note": "",
"url": "https://example.com/@foo",
"uri": "https://example.com/@foo",
},
"status": \(statusB)
""".data(using: .utf8)!
lazy var mentionB = try! decoder.decode(Notification.self, from: mentionBData)
func testGroupSimple() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
}
func testGroupWithOtherGroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
])
}
func testDontGroupWithUngroupableInBetween() {
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
XCTAssertEqual(groups, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
])
}
func testMergeSimpleGroups() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!
])
}
func testMergeGroupsWithOtherGroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [likeB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
])
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
XCTAssertEqual(merged2, [
NotificationGroup(notifications: [likeA1, likeA2])!,
NotificationGroup(notifications: [likeB])!,
])
let group4 = NotificationGroup(notifications: [likeB2])!
let group5 = NotificationGroup(notifications: [mentionB])!
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
print(merged3.count)
XCTAssertEqual(merged3, [
group1,
group5,
NotificationGroup(notifications: [likeB, likeB2]),
group3
])
}
func testDontMergeWithUngroupableInBetween() {
let group1 = NotificationGroup(notifications: [likeA1])!
let group2 = NotificationGroup(notifications: [mentionB])!
let group3 = NotificationGroup(notifications: [likeA2])!
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
XCTAssertEqual(merged, [
NotificationGroup(notifications: [likeA1])!,
NotificationGroup(notifications: [mentionB])!,
NotificationGroup(notifications: [likeA2])!,
])
}
}

View File

@ -235,6 +235,11 @@
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */; }; D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */; };
D6B81F442560390300F6E31D /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F432560390300F6E31D /* MenuController.swift */; }; D6B81F442560390300F6E31D /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F432560390300F6E31D /* MenuController.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */; };
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366A281EE77E00237D0E /* PollVoteButton.swift */; };
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; };
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.swift */; };
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */; };
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; }; D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; };
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; }; D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; };
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; }; D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; };
@ -280,13 +285,14 @@
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 */; };
D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */; }; D6E343B0265AAD6B00C4AA01 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E343AE265AAD6B00C4AA01 /* MainInterface.storyboard */; };
D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D6E343B4265AAD6B00C4AA01 /* OpenInTusker.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D6E343A8265AAD6B00C4AA01 /* OpenInTusker.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = D6E343B9265AAD8C00C4AA01 /* Action.js */; }; D6E343BA265AAD8C00C4AA01 /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = D6E343B9265AAD8C00C4AA01 /* Action.js */; };
D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; }; D6E4267725327FB400C02E1C /* ComposeAutocompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4267625327FB400C02E1C /* ComposeAutocompleteView.swift */; };
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; }; D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */; };
D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; }; D6E426AD25334DA500C02E1C /* FuzzyMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426AC25334DA500C02E1C /* FuzzyMatcherTests.swift */; };
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; }; D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */; };
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; }; D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = D6E57FA525C26FAB00341037 /* Localizable.stringsdict */; };
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E9CDA7281A427800BBC98E /* PostService.swift */; };
D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; }; D6EAE0DB2550CC8A002DB0AC /* FocusableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */; };
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; }; D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; }; D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
@ -574,6 +580,11 @@
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; }; D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; };
D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; }; D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarMyProfileCollectionViewCell.swift; sourceTree = "<group>"; };
D6B9366A281EE77E00237D0E /* PollVoteButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollVoteButton.swift; sourceTree = "<group>"; };
D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.swift; sourceTree = "<group>"; };
D6B9366E2828452F00237D0E /* SavedHashtag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedHashtag.swift; sourceTree = "<group>"; };
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSManagedObjectContext+Helpers.swift"; sourceTree = "<group>"; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; }; D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; }; D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; };
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; }; D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; };
@ -635,6 +646,7 @@
D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; }; D6E426B225337C7000C02E1C /* CustomEmojiImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiImageView.swift; sourceTree = "<group>"; };
D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; }; D6E4885C24A2890C0011C13E /* Tusker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tusker.entitlements; sourceTree = "<group>"; };
D6E57FA425C26FAB00341037 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; }; D6E57FA425C26FAB00341037 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
D6E9CDA7281A427800BBC98E /* PostService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostService.swift; sourceTree = "<group>"; };
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; }; D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusableTextField.swift; sourceTree = "<group>"; };
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; }; D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; }; D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
@ -745,6 +757,7 @@
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */, D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */,
D623A5402635FB3C0095BD04 /* PollOptionView.swift */, D623A5402635FB3C0095BD04 /* PollOptionView.swift */,
D623A542263634100095BD04 /* PollOptionCheckboxView.swift */, D623A542263634100095BD04 /* PollOptionCheckboxView.swift */,
D6B9366A281EE77E00237D0E /* PollVoteButton.swift */,
); );
path = Poll; path = Poll;
sourceTree = "<group>"; sourceTree = "<group>";
@ -840,7 +853,10 @@
D60E2F232442372B005F8713 /* StatusMO.swift */, D60E2F232442372B005F8713 /* StatusMO.swift */,
D60E2F252442372B005F8713 /* AccountMO.swift */, D60E2F252442372B005F8713 /* AccountMO.swift */,
D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */, D6DF95C02533F5DE0027A9B6 /* RelationshipMO.swift */,
D6B9366E2828452F00237D0E /* SavedHashtag.swift */,
D6B9366C2828444F00237D0E /* SavedInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
); );
path = CoreData; path = CoreData;
sourceTree = "<group>"; sourceTree = "<group>";
@ -891,6 +907,7 @@
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */, D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */,
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */, 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */,
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */, D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */,
D6B93666281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift */,
D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */, D68E525A24A3D77E0054355A /* TuskerRootViewController.swift */,
); );
path = Main; path = Main;
@ -1366,6 +1383,7 @@
D61959D2241E846D00A37B8E /* Models */, D61959D2241E846D00A37B8E /* Models */,
D663626021360A9600C9CBA2 /* Preferences */, D663626021360A9600C9CBA2 /* Preferences */,
D641C780213DD7C4004B4513 /* Screens */, D641C780213DD7C4004B4513 /* Screens */,
D6E9CDA6281A426700BBC98E /* Services */,
D62D241E217AA46B005076CC /* Shortcuts */, D62D241E217AA46B005076CC /* Shortcuts */,
D67B506B250B28FF00FAECFB /* Vendor */, D67B506B250B28FF00FAECFB /* Vendor */,
D6BED1722126661300F02DA0 /* Views */, D6BED1722126661300F02DA0 /* Views */,
@ -1422,6 +1440,14 @@
path = OpenInTusker; path = OpenInTusker;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6E9CDA6281A426700BBC98E /* Services */ = {
isa = PBXGroup;
children = (
D6E9CDA7281A427800BBC98E /* PostService.swift */,
);
path = Services;
sourceTree = "<group>";
};
D6F1F84E2193B9BE00F5FE67 /* Caching */ = { D6F1F84E2193B9BE00F5FE67 /* Caching */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1776,6 +1802,8 @@
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */, D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */, D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */, D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */,
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */, D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */, D662AEF2263A4BE10082A153 /* ComposePollView.swift in Sources */,
@ -1827,8 +1855,10 @@
D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */, D64AAE9726C88DC400FC57FB /* ToastConfiguration.swift in Sources */,
D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */, D6093FB025BE0B01004811E6 /* TrendingHashtagTableViewCell.swift in Sources */,
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */, D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */,
D6E9CDA8281A427800BBC98E /* PostService.swift in Sources */,
D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */, D6B053A223BD2C0600A066FA /* AssetPickerViewController.swift in Sources */,
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */, D627944A23A6AD6100D38C68 /* BookmarksTableViewController.swift in Sources */,
D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */, D64BC19223C271D9000D0238 /* MastodonActivity.swift in Sources */,
D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */, D6945C3A23AC75E2005C403C /* FindInstanceViewController.swift in Sources */,
@ -1843,6 +1873,7 @@
D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */, D64AAE9526C88C5000FC57FB /* ToastableViewController.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 */,
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 */,
@ -1914,6 +1945,7 @@
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */, D6620ACE2511A0ED00312CA0 /* StatusStateResolver.swift in Sources */,
D6B9366B281EE77E00237D0E /* PollVoteButton.swift in Sources */,
D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */, D6BC9DB5232D4CE3002CA326 /* NotificationsMode.swift in Sources */,
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */, D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */, D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
@ -2002,6 +2034,7 @@
}; };
D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */ = { D6E343B3265AAD6B00C4AA01 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
platformFilter = ios;
target = D6E343A7265AAD6B00C4AA01 /* OpenInTusker */; target = D6E343A7265AAD6B00C4AA01 /* OpenInTusker */;
targetProxy = D6E343B2265AAD6B00C4AA01 /* PBXContainerItemProxy */; targetProxy = D6E343B2265AAD6B00C4AA01 /* PBXContainerItemProxy */;
}; };
@ -2165,7 +2198,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 = 27; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2196,7 +2229,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 = 27; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = V4WK9KR9U2; DEVELOPMENT_TEAM = V4WK9KR9U2;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
@ -2306,7 +2339,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 = 27; CURRENT_PROJECT_VERSION = 30;
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;
@ -2333,7 +2366,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 = 27; CURRENT_PROJECT_VERSION = 30;
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

@ -28,6 +28,16 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES">
<Testables> <Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PachydermTests"
BuildableName = "PachydermTests"
BlueprintName = "PachydermTests"
ReferencedContainer = "container:Pachyderm">
</BuildableReference>
</TestableReference>
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction

View File

@ -29,7 +29,7 @@ class BookmarkStatusActivity: StatusActivity {
let request = Status.bookmark(status.id) let request = Status.bookmark(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: status)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -28,7 +28,7 @@ class MuteConversationActivity: StatusActivity {
let request = Status.muteConversation(status.id) let request = Status.muteConversation(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: status)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -28,7 +28,7 @@ class PinStatusActivity: StatusActivity {
let request = Status.pin(status.id) let request = Status.pin(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: status)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -29,7 +29,7 @@ class UnbookmarkStatusActivity: StatusActivity {
let request = Status.unbookmark(status.id) let request = Status.unbookmark(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: status)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -28,7 +28,7 @@ class UnmuteConversationActivity: StatusActivity {
let request = Status.unmuteConversation(status.id) let request = Status.unmuteConversation(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: status)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -28,7 +28,7 @@ class UnpinStatusActivity: StatusActivity {
let request = Status.unpin(status.id) let request = Status.unpin(status.id)
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
if case let .success(status, _) = response { if case let .success(status, _) = response {
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: status)
} else { } else {
// todo: display error message // todo: display error message
UINotificationFeedbackGenerator().notificationOccurred(.error) UINotificationFeedbackGenerator().notificationOccurred(.error)

View File

@ -8,6 +8,10 @@
import UIKit import UIKit
import CrashReporter import CrashReporter
import CoreData
import OSLog
let stateRestorationLogger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "StateRestoration")
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -27,6 +31,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
AudioSessionHelper.setDefault() AudioSessionHelper.setDefault()
} }
if let oldSavedData = SavedDataManager.load() {
do {
for account in oldSavedData.accountIDs {
guard let account = LocalData.shared.getAccount(id: account) else {
continue
}
let controller = MastodonController.getForAccount(account)
try oldSavedData.migrateToCoreData(accountID: account.id, context: controller.persistentContainer.viewContext)
if controller.persistentContainer.viewContext.hasChanges {
try controller.persistentContainer.viewContext.save()
}
}
try SavedDataManager.destroy()
} catch {
// no-op
}
}
return true return true
} }
@ -45,7 +67,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
override func buildMenu(with builder: UIMenuBuilder) { override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
if builder.system == .main { if builder.system == .main {
MenuController.buildMainMenu(builder: builder) MenuController.buildMainMenu(builder: builder)
@ -74,10 +95,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
.bookmarks, .bookmarks,
.myProfile, .myProfile,
.showProfile: .showProfile:
if activity.displaysAuxiliaryScene {
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
return "auxiliary" return "auxiliary"
} else {
return "main-scene"
}
case .newPost: case .newPost:
return "compose" return "compose"
} }
} }
@objc func closeWindow() {
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) else {
return
}
UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil)
}
} }

View File

@ -137,7 +137,7 @@ class MastodonController: ObservableObject {
} else { } else {
// the first time the user's account is added to the store, // the first time the user's account is added to the store,
// increment its reference count so that it's never removed // increment its reference count so that it's never removed
self.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) self.persistentContainer.addOrUpdate(account: account)
} }
completion?(.success(account)) completion?(.success(account))
} }

View File

@ -52,20 +52,21 @@ struct MenuController {
static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift]) static let prevSubTabCommand = UIKeyCommand(title: "Previous Sub Tab", action: #selector(TabbedPageViewController.selectPrevPage), input: "[", modifierFlags: [.command, .shift])
static func buildMainMenu(builder: UIMenuBuilder) { static func buildMainMenu(builder: UIMenuBuilder) {
builder.insertChild(buildFileMenu(), atStartOfMenu: .file) builder.replace(menu: .file, with: buildFileMenu())
builder.insertChild(buildSubTabMenu(), atStartOfMenu: .view) builder.insertChild(buildSubTabMenu(), atStartOfMenu: .view)
builder.insertChild(buildSidebarShortcuts(), atStartOfMenu: .view) builder.insertChild(buildSidebarShortcuts(), atStartOfMenu: .view)
} }
private static func buildFileMenu() -> UIMenu { private static func buildFileMenu() -> UIMenu {
return UIMenu( return UIMenu(
title: "", title: "File",
image: nil, image: nil,
identifier: nil, identifier: nil,
options: .displayInline, options: [],
children: [ children: [
composeCommand, composeCommand,
refreshCommand(discoverabilityTitle: nil), refreshCommand(discoverabilityTitle: nil),
UIKeyCommand(title: "Close", action: #selector(AppDelegate.closeWindow), input: "w", modifierFlags: .command),
] ]
) )
} }

View File

@ -32,10 +32,10 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
@NSManaged public var locked: Bool @NSManaged public var locked: Bool
@NSManaged public var movedCD: Bool @NSManaged public var movedCD: Bool
@NSManaged public var note: String @NSManaged public var note: String
@NSManaged public var referenceCount: Int
@NSManaged public var statusesCount: Int @NSManaged public var statusesCount: Int
@NSManaged public var url: URL @NSManaged public var url: URL
@NSManaged public var username: String @NSManaged public var username: String
@NSManaged public var lastFetchedAt: Date?
@NSManaged public var movedTo: AccountMO? @NSManaged public var movedTo: AccountMO?
@LazilyDecoding(arrayFrom: \AccountMO.emojisData) @LazilyDecoding(arrayFrom: \AccountMO.emojisData)
@ -47,30 +47,21 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
public var bot: Bool? { botCD } public var bot: Bool? { botCD }
public var moved: Bool? { movedCD } public var moved: Bool? { movedCD }
func incrementReferenceCount() { public override func awakeFromFetch() {
referenceCount += 1 super.awakeFromFetch()
}
func decrementReferenceCount() { managedObjectContext?.perform {
referenceCount -= 1 self.lastFetchedAt = Date()
if referenceCount <= 0 {
managedObjectContext!.delete(self)
} }
} }
public override func prepareForDeletion() {
super.prepareForDeletion()
movedTo?.decrementReferenceCount()
}
} }
extension AccountMO { extension AccountMO {
convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) { convenience init(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context) self.init(context: context)
self.updateFrom(apiAccount: account, container: container) self.updateFrom(apiAccount: account, container: container)
self.lastFetchedAt = Date()
movedTo?.incrementReferenceCount()
} }
func updateFrom(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore) { func updateFrom(apiAccount account: Pachyderm.Account, container: MastodonCachePersistentStore) {

View File

@ -50,6 +50,8 @@ class MastodonCachePersistentStore: NSPersistentContainer {
fatalError("Unable to load persistent store: \(error)") fatalError("Unable to load persistent store: \(error)")
} }
} }
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
} }
func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? { func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? {
@ -65,26 +67,19 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
@discardableResult @discardableResult
private func upsert(status: Status, incrementReferenceCount: Bool, context: NSManagedObjectContext) -> StatusMO { private func upsert(status: Status, context: NSManagedObjectContext) -> StatusMO {
if let statusMO = self.status(for: status.id, in: context) { if let statusMO = self.status(for: status.id, in: context) {
statusMO.updateFrom(apiStatus: status, container: self) statusMO.updateFrom(apiStatus: status, container: self)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO return statusMO
} else { } else {
let statusMO = StatusMO(apiStatus: status, container: self, context: context) return StatusMO(apiStatus: status, container: self, context: context)
if incrementReferenceCount {
statusMO.incrementReferenceCount()
}
return statusMO
} }
} }
func addOrUpdate(status: Status, incrementReferenceCount: Bool, context: NSManagedObjectContext? = nil, completion: ((StatusMO) -> Void)? = nil) { func addOrUpdate(status: Status, context: NSManagedObjectContext? = nil, completion: ((StatusMO) -> Void)? = nil) {
let context = context ?? backgroundContext let context = context ?? backgroundContext
context.perform { context.perform {
let statusMO = self.upsert(status: status, incrementReferenceCount: incrementReferenceCount, context: context) let statusMO = self.upsert(status: status, context: context)
if context.hasChanges { if context.hasChanges {
try! context.save() try! context.save()
} }
@ -93,9 +88,19 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
@MainActor
func addOrUpdateOnViewContext(status: Status) -> StatusMO {
let statusMO = self.upsert(status: status, context: viewContext)
if viewContext.hasChanges {
try! viewContext.save()
}
statusSubject.send(status.id)
return statusMO
}
func addAll(statuses: [Status], completion: (() -> Void)? = nil) { func addAll(statuses: [Status], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
if self.backgroundContext.hasChanges { if self.backgroundContext.hasChanges {
try! self.backgroundContext.save() try! self.backgroundContext.save()
} }
@ -104,6 +109,14 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
func addAll(statuses: [Status]) async {
return await withCheckedContinuation { continuation in
addAll(statuses: statuses) {
continuation.resume()
}
}
}
func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? { func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? {
let context = context ?? viewContext let context = context ?? viewContext
let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest() let request: NSFetchRequest<AccountMO> = AccountMO.fetchRequest()
@ -117,25 +130,18 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
@discardableResult @discardableResult
private func upsert(account: Account, incrementReferenceCount: Bool) -> AccountMO { private func upsert(account: Account) -> AccountMO {
if let accountMO = self.account(for: account.id, in: self.backgroundContext) { if let accountMO = self.account(for: account.id, in: self.backgroundContext) {
accountMO.updateFrom(apiAccount: account, container: self) accountMO.updateFrom(apiAccount: account, container: self)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO return accountMO
} else { } else {
let accountMO = AccountMO(apiAccount: account, container: self, context: self.backgroundContext) return AccountMO(apiAccount: account, container: self, context: self.backgroundContext)
if incrementReferenceCount {
accountMO.incrementReferenceCount()
}
return accountMO
} }
} }
func addOrUpdate(account: Account, incrementReferenceCount: Bool, completion: ((AccountMO) -> Void)? = nil) { func addOrUpdate(account: Account, completion: ((AccountMO) -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
let accountMO = self.upsert(account: account, incrementReferenceCount: incrementReferenceCount) let accountMO = self.upsert(account: account)
if self.backgroundContext.hasChanges { if self.backgroundContext.hasChanges {
try! self.backgroundContext.save() try! self.backgroundContext.save()
} }
@ -180,7 +186,7 @@ class MastodonCachePersistentStore: NSPersistentContainer {
func addAll(accounts: [Account], completion: (() -> Void)? = nil) { func addAll(accounts: [Account], completion: (() -> Void)? = nil) {
backgroundContext.perform { backgroundContext.perform {
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) } accounts.forEach { self.upsert(account: $0) }
if self.backgroundContext.hasChanges { if self.backgroundContext.hasChanges {
try! self.backgroundContext.save() try! self.backgroundContext.save()
} }
@ -195,8 +201,8 @@ class MastodonCachePersistentStore: NSPersistentContainer {
// filter out mentions, otherwise we would double increment the reference count of those accounts // filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification // since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account } let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) } accounts.forEach { self.upsert(account: $0) }
if self.backgroundContext.hasChanges { if self.backgroundContext.hasChanges {
try! self.backgroundContext.save() try! self.backgroundContext.save()
} }
@ -212,10 +218,10 @@ class MastodonCachePersistentStore: NSPersistentContainer {
var updatedStatuses = [String]() var updatedStatuses = [String]()
block(self.backgroundContext, { (accounts) in block(self.backgroundContext, { (accounts) in
accounts.forEach { self.upsert(account: $0, incrementReferenceCount: true) } accounts.forEach { self.upsert(account: $0) }
updatedAccounts.append(contentsOf: accounts.map { $0.id }) updatedAccounts.append(contentsOf: accounts.map { $0.id })
}, { (statuses) in }, { (statuses) in
statuses.forEach { self.upsert(status: $0, incrementReferenceCount: true, context: self.backgroundContext) } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
updatedStatuses.append(contentsOf: statuses.map { $0.id }) updatedStatuses.append(contentsOf: statuses.map { $0.id })
}) })
@ -229,4 +235,43 @@ class MastodonCachePersistentStore: NSPersistentContainer {
} }
} }
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
let changes = hasChangedSavedHashtagsOrInstances(notification)
if changes.hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if changes.instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
}
private func hasChangedSavedHashtagsOrInstances(_ notification: Foundation.Notification) -> (hashtags: Bool, instances: Bool) {
var changes: (hashtags: Bool, instances: Bool) = (false, false)
if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set<NSManagedObject> {
for object in inserted {
if object is SavedHashtag {
changes.hashtags = true
} else if object is SavedInstance {
changes.instances = true
}
if changes.hashtags && changes.instances {
return changes
}
}
}
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject> {
for object in deleted {
if object is SavedHashtag {
changes.hashtags = true
} else if object is SavedInstance {
changes.instances = true
}
if changes.hashtags && changes.instances {
return changes
}
}
}
return changes
}
} }

View File

@ -0,0 +1,20 @@
//
// NSManagedObjectContext+Helpers.swift
// Tusker
//
// Created by Shadowfacts on 5/9/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import CoreData
extension NSManagedObjectContext {
func objectExists<T: NSFetchRequestResult>(for request: NSFetchRequest<T>) -> Bool {
switch try? count(for: request) {
case nil, 0, NSNotFound:
return false
default:
return true
}
}
}

View File

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

View File

@ -0,0 +1,34 @@
//
// SavedInstance.swift
// Tusker
//
// Created by Shadowfacts on 5/8/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
@objc(SavedInstance)
public final class SavedInstance: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<SavedInstance> {
return NSFetchRequest<SavedInstance>(entityName: "SavedInstance")
}
@nonobjc public class func fetchRequest(url: URL) -> NSFetchRequest<SavedInstance> {
let req = fetchRequest()
req.predicate = NSPredicate(format: "url = %@", url as NSURL)
return req
}
@NSManaged public var url: URL
}
extension SavedInstance {
convenience init(url: URL, context: NSManagedObjectContext) {
self.init(context: context)
self.url = url
}
}

View File

@ -36,7 +36,6 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged private var pinnedInternal: Bool @NSManaged private var pinnedInternal: Bool
@NSManaged public var reblogged: Bool @NSManaged public var reblogged: Bool
@NSManaged public var reblogsCount: Int @NSManaged public var reblogsCount: Int
@NSManaged public var referenceCount: Int
@NSManaged public var sensitive: Bool @NSManaged public var sensitive: Bool
@NSManaged public var spoilerText: String @NSManaged public var spoilerText: String
@NSManaged public var uri: String // todo: are both uri and url necessary? @NSManaged public var uri: String // todo: are both uri and url necessary?
@ -46,6 +45,7 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@NSManaged public var account: AccountMO @NSManaged public var account: AccountMO
@NSManaged public var reblog: StatusMO? @NSManaged public var reblog: StatusMO?
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var lastFetchedAt: Date?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment] public var attachments: [Attachment]
@ -77,32 +77,21 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
} }
} }
func incrementReferenceCount() { public override func awakeFromFetch() {
referenceCount += 1 super.awakeFromFetch()
}
func decrementReferenceCount() { managedObjectContext?.perform {
referenceCount -= 1 self.lastFetchedAt = Date()
if referenceCount <= 0 {
managedObjectContext!.delete(self)
} }
} }
public override func prepareForDeletion() {
super.prepareForDeletion()
reblog?.decrementReferenceCount()
account.decrementReferenceCount()
}
} }
extension StatusMO { extension StatusMO {
convenience init(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore, context: NSManagedObjectContext) { convenience init(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore, context: NSManagedObjectContext) {
self.init(context: context) self.init(context: context)
self.updateFrom(apiStatus: status, container: container) self.updateFrom(apiStatus: status, container: container)
self.lastFetchedAt = Date()
reblog?.incrementReferenceCount()
account.incrementReferenceCount()
} }
func updateFrom(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore) { func updateFrom(apiStatus status: Pachyderm.Status, container: MastodonCachePersistentStore) {

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21E230" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="avatar" optional="YES" attributeType="URI"/> <attribute name="avatar" optional="YES" attributeType="URI"/>
@ -12,10 +12,10 @@
<attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="followingCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="header" optional="YES" attributeType="URI"/> <attribute name="header" optional="YES" attributeType="URI"/>
<attribute name="id" attributeType="String"/> <attribute name="id" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="locked" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="locked" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="movedCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="movedCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="note" attributeType="String"/> <attribute name="note" attributeType="String"/>
<attribute name="referenceCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="statusesCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="url" attributeType="URI"/> <attribute name="url" attributeType="URI"/>
<attribute name="username" attributeType="String"/> <attribute name="username" attributeType="String"/>
@ -40,6 +40,23 @@
<attribute name="showingReblogs" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="showingReblogs" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="relationship" inverseEntity="Account"/>
</entity> </entity>
<entity name="SavedHashtag" representedClassName="SavedHashtag" syncable="YES">
<attribute name="name" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="name"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="SavedInstance" representedClassName="SavedInstance" syncable="YES">
<attribute name="url" attributeType="URI"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="url"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="Status" representedClassName="StatusMO" syncable="YES"> <entity name="Status" representedClassName="StatusMO" syncable="YES">
<attribute name="applicationName" optional="YES" attributeType="String"/> <attribute name="applicationName" optional="YES" attributeType="String"/>
<attribute name="attachmentsData" attributeType="Binary"/> <attribute name="attachmentsData" attributeType="Binary"/>
@ -54,6 +71,7 @@
<attribute name="id" attributeType="String"/> <attribute name="id" attributeType="String"/>
<attribute name="inReplyToAccountID" optional="YES" attributeType="String"/> <attribute name="inReplyToAccountID" optional="YES" attributeType="String"/>
<attribute name="inReplyToID" optional="YES" attributeType="String"/> <attribute name="inReplyToID" optional="YES" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="localOnly" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="mentionsData" attributeType="Binary"/> <attribute name="mentionsData" attributeType="Binary"/>
<attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="muted" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
@ -61,7 +79,6 @@
<attribute name="pollData" optional="YES" attributeType="Binary"/> <attribute name="pollData" optional="YES" attributeType="Binary"/>
<attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="reblogged" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="reblogsCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="referenceCount" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="sensitive" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="spoilerText" attributeType="String"/> <attribute name="spoilerText" attributeType="String"/>
<attribute name="uri" attributeType="String"/> <attribute name="uri" attributeType="String"/>
@ -76,8 +93,10 @@
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<elements> <elements>
<element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="329"/> <element name="Account" positionX="169.21875" positionY="78.9609375" width="128" height="343"/>
<element name="Relationship" positionX="63" positionY="135" width="128" height="208"/> <element name="Relationship" positionX="63" positionY="135" width="128" height="194"/>
<element name="Status" positionX="-63" positionY="-18" width="128" height="449"/> <element name="Status" positionX="-63" positionY="-18" width="128" height="449"/>
<element name="SavedInstance" positionX="63" positionY="144" width="128" height="44"/>
<element name="SavedHashtag" positionX="72" positionY="153" width="128" height="59"/>
</elements> </elements>
</model> </model>

View File

@ -24,6 +24,19 @@ extension Status.Visibility {
} }
} }
var subtitle: String {
switch self {
case .public:
return "Everyone"
case .unlisted:
return "Hidden from public timelines"
case .private:
return "Followers only"
case .direct:
return "Mentioned users only"
}
}
var imageName: String { var imageName: String {
switch self { switch self {
case .public: case .public:

View File

@ -58,8 +58,8 @@ public struct LazilyDecoding<Enclosing, Value: Codable> {
} }
extension LazilyDecoding { extension LazilyDecoding {
init(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) { init<T: Codable>(arrayFrom keyPath: ReferenceWritableKeyPath<Enclosing, Data?>) where Value == [T] {
self.init(from: keyPath, fallback: [] as! Value) self.init(from: keyPath, fallback: [])
} }
} }

View File

@ -10,6 +10,7 @@ import UIKit
import Pachyderm import Pachyderm
import CrashReporter import CrashReporter
import MessageUI import MessageUI
import CoreData
class MainSceneDelegate: UIResponder, UIWindowSceneDelegate { class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
@ -17,12 +18,17 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
private var launchActivity: NSUserActivity? private var launchActivity: NSUserActivity?
var rootViewController: TuskerRootViewController? {
window?.rootViewController as? TuskerRootViewController
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return } guard let windowScene = scene as? UIWindowScene else { return }
if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity { if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
launchActivity = activity launchActivity = activity
} }
stateRestorationLogger.info("MainSceneDelegate.launchActivity = \(self.launchActivity?.activityType ?? "nil", privacy: .public)")
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
@ -56,7 +62,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
if url.host == "x-callback-url" { if url.host == "x-callback-url" {
_ = XCBManager.handle(url: url) _ = XCBManager.handle(url: url)
} else if var components = URLComponents(url: url, resolvingAgainstBaseURL: false), } else if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let rootViewController = window!.rootViewController as? TuskerRootViewController { let rootViewController = rootViewController {
components.scheme = "https" components.scheme = "https"
let query = components.string! let query = components.string!
rootViewController.performSearch(query: query) rootViewController.performSearch(query: query)
@ -64,7 +70,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
_ = userActivity.handleResume() stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
} }
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -116,7 +123,22 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
rootVC.sceneDidEnterBackground() rootVC.sceneDidEnterBackground()
} }
try! scene.session.mastodonController?.persistentContainer.viewContext.save() if let context = scene.session.mastodonController?.persistentContainer.viewContext {
var minDate = Date()
minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
_ = try? context.execute(deleteStatusReq)
let accountReq: NSFetchRequest<NSFetchRequestResult> = AccountMO.fetchRequest()
accountReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
let deleteAccountReq = NSBatchDeleteRequest(fetchRequest: accountReq)
_ = try? context.execute(deleteAccountReq)
try? context.save()
}
} }
private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) { private func handlePendingCrashReport(_ report: PLCrashReport, session: UISceneSession) {
@ -149,6 +171,11 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
activateAccount(account, animated: false) activateAccount(account, animated: false)
if let activity = launchActivity,
activity.activityType != UserActivityType.mainScene.rawValue {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
}
} else { } else {
window!.rootViewController = createOnboardingUI() window!.rootViewController = createOnboardingUI()
} }
@ -202,6 +229,12 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate {
window?.overrideUserInterfaceStyle = Preferences.shared.theme window?.overrideUserInterfaceStyle = Preferences.shared.theme
} }
func showAddAccount() {
rootViewController?.presentPreferences {
NotificationCenter.default.post(name: .addAccount, object: nil)
}
}
} }
extension MainSceneDelegate: OnboardingViewControllerDelegate { extension MainSceneDelegate: OnboardingViewControllerDelegate {

View File

@ -88,8 +88,13 @@ enum CompositionAttachmentData {
options.isNetworkAccessAllowed = true options.isNetworkAccessAllowed = true
options.version = .current options.version = .current
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
guard let exportSession = exportSession else { fatalError("failed to create export session") } if let exportSession = exportSession {
CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion) CompositionAttachmentData.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
} }
} else { } else {
fatalError("assetType must be either image or video") fatalError("assetType must be either image or video")
@ -97,7 +102,8 @@ enum CompositionAttachmentData {
case let .video(url): case let .video(url):
let asset = AVURLAsset(url: url) let asset = AVURLAsset(url: url)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else { guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
fatalError("failed to create export session") completion(.failure(.noVideoExportSession))
return
} }
CompositionAttachmentData.exportVideoData(session: session, completion: completion) CompositionAttachmentData.exportVideoData(session: session, completion: completion)
@ -112,14 +118,14 @@ enum CompositionAttachmentData {
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously { session.exportAsynchronously {
guard session.status == .completed else { guard session.status == .completed else {
completion(.failure(.export(session.error!))) completion(.failure(.videoExport(session.error!)))
return return
} }
do { do {
let data = try Data(contentsOf: session.outputURL!) let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, "video/mp4"))) completion(.success((data, "video/mp4")))
} catch { } catch {
completion(.failure(.export(error))) completion(.failure(.videoExport(error)))
} }
} }
} }
@ -128,9 +134,21 @@ enum CompositionAttachmentData {
case image, video case image, video
} }
enum Error: Swift.Error { enum Error: Swift.Error, LocalizedError {
case missingData case missingData
case export(Swift.Error) case videoExport(Swift.Error)
case noVideoExportSession
var localizedDescription: String {
switch self {
case .missingData:
return "Missing Data"
case .videoExport(let error):
return "Exporting video: \(error)"
case .noVideoExportSession:
return "Couldn't create video export session"
}
}
} }
} }

View File

@ -226,6 +226,8 @@ extension MastodonController {
draft.visibility = visibility draft.visibility = visibility
draft.contentWarning = contentWarning draft.contentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty draft.contentWarningEnabled = !contentWarning.isEmpty
DraftsManager.shared.add(draft)
return draft return draft
} }

View File

@ -8,101 +8,68 @@
import Foundation import Foundation
import Pachyderm import Pachyderm
import CoreData
class SavedDataManager: Codable { class SavedDataManager: Codable {
private(set) static var shared: SavedDataManager = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist") private static var archiveURL = SavedDataManager.documentsDirectory.appendingPathComponent("saved_data").appendingPathExtension("plist")
static func save() { static func load() -> SavedDataManager? {
DispatchQueue.global(qos: .utility).async {
let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared)
try? data?.write(to: archiveURL, options: .noFileProtection)
}
}
static func load() -> SavedDataManager {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: archiveURL),
let savedHashtagsManager = try? decoder.decode(Self.self, from: data) { let savedHashtagsManager = try? decoder.decode(Self.self, from: data) {
return savedHashtagsManager return savedHashtagsManager
} }
return SavedDataManager() return nil
}
static func destroy() throws {
try FileManager.default.removeItem(at: archiveURL)
} }
private init() {} private init() {}
private var savedHashtags: [String: [Hashtag]] = [:] { private(set) var savedHashtags: [String: [Hashtag]] = [:]
didSet { private(set) var savedInstances: [String: [URL]] = [:]
SavedDataManager.save()
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) var accountIDs: Set<String> {
} var s = Set<String>()
savedHashtags.keys.forEach { s.insert($0) }
savedInstances.keys.forEach { s.insert($0) }
return s
} }
private var savedInstances: [String: [URL]] = [:] { private func save() {
didSet { let encoder = PropertyListEncoder()
SavedDataManager.save() let data = try? encoder.encode(self)
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) try? data?.write(to: SavedDataManager.archiveURL, options: .noFileProtection)
}
} }
func sortedHashtags(for account: LocalData.UserAccountInfo) -> [Hashtag] { func migrateToCoreData(accountID: String, context: NSManagedObjectContext) throws {
if let hashtags = savedHashtags[account.id] { var changed = false
return hashtags.sorted(by: { $0.name < $1.name })
} else { if let hashtags = savedHashtags[accountID] {
return [] let objects = hashtags.map {
["url": $0.url, "name": $0.name]
} }
let hashtagsReq = NSBatchInsertRequest(entity: SavedHashtag.entity(), objects: objects)
try context.execute(hashtagsReq)
savedHashtags.removeValue(forKey: accountID)
changed = true
} }
func isSaved(hashtag: Hashtag, for account: LocalData.UserAccountInfo) -> Bool { if let instances = savedInstances[accountID] {
return savedHashtags[account.id]?.contains(hashtag) ?? false let objects = instances.map {
["url": $0]
}
let instancesReq = NSBatchInsertRequest(entity: SavedInstance.entity(), objects: objects)
try context.execute(instancesReq)
savedInstances.removeValue(forKey: accountID)
changed = true
} }
func add(hashtag: Hashtag, for account: LocalData.UserAccountInfo) { if changed {
if isSaved(hashtag: hashtag, for: account) { save()
return
}
if var saved = savedHashtags[account.id] {
saved.append(hashtag)
savedHashtags[account.id] = saved
} else {
savedHashtags[account.id] = [hashtag]
}
}
func remove(hashtag: Hashtag, for account: LocalData.UserAccountInfo) {
guard isSaved(hashtag: hashtag, for: account) else { return }
if var saved = savedHashtags[account.id] {
saved.removeAll(where: { $0.name == hashtag.name })
savedHashtags[account.id] = saved
}
}
func savedInstances(for account: LocalData.UserAccountInfo) -> [URL] {
return savedInstances[account.id] ?? []
}
func isSaved(instance url: URL, for account: LocalData.UserAccountInfo) -> Bool {
return savedInstances[account.id]?.contains(url) ?? false
}
func add(instance url: URL, for account: LocalData.UserAccountInfo) {
if isSaved(instance: url, for: account) { return }
if var saved = savedInstances[account.id] {
saved.append(url)
savedInstances[account.id] = saved
} else {
savedInstances[account.id] = [url]
}
}
func remove(instance url: URL, for account: LocalData.UserAccountInfo) {
guard isSaved(instance: url, for: account) else { return }
if var saved = savedInstances[account.id] {
saved.removeAll(where: { $0 == url })
savedInstances[account.id] = saved
} }
} }
} }

View File

@ -67,3 +67,9 @@ class AccountListTableViewController: EnhancedTableViewController {
extension AccountListTableViewController: TuskerNavigationDelegate { extension AccountListTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension AccountListTableViewController: ToastableViewController {
}
extension AccountListTableViewController: MenuActionProvider {
}

View File

@ -134,7 +134,7 @@ class BookmarksTableViewController: EnhancedTableViewController {
let request = Status.unbookmark(status.id) let request = Status.unbookmark(status.id)
self.mastodonController.run(request) { (response) in self.mastodonController.run(request) { (response) in
guard case let .success(newStatus, _) = response else { fatalError() } guard case let .success(newStatus, _) = response else { fatalError() }
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
self.statuses.remove(at: indexPath.row) self.statuses.remove(at: indexPath.row)
} }
} }
@ -153,6 +153,15 @@ class BookmarksTableViewController: EnhancedTableViewController {
} }
extension BookmarksTableViewController: TuskerNavigationDelegate {
}
extension BookmarksTableViewController: ToastableViewController {
}
extension BookmarksTableViewController: MenuActionProvider {
}
extension BookmarksTableViewController: StatusTableViewCellDelegate { extension BookmarksTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }

View File

@ -376,7 +376,8 @@ struct ComposeAutocompleteHashtagsView: View {
} }
private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) { private func updateHashtags(searchResults: [Hashtag], trendingTags: [Hashtag], query: String) {
let savedTags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) let savedTags = ((try? mastodonController.persistentContainer.viewContext.fetch(SavedHashtag.fetchRequest())) ?? [])
.map { Hashtag(name: $0.name, url: $0.url) }
hashtags = (searchResults + savedTags + trendingTags) hashtags = (searchResults + savedTags + trendingTags)
.map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in .map { (tag) -> (Hashtag, (matched: Bool, score: Int)) in

View File

@ -52,6 +52,9 @@ struct ComposeEmojiTextField: UIViewRepresentable {
view.backgroundColor = backgroundColor view.backgroundColor = backgroundColor
// otherwise when the text gets too wide it starts expanding the ComposeView
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
context.coordinator.textField = view context.coordinator.textField = view
context.coordinator.uiState = uiState context.coordinator.uiState = uiState
context.coordinator.text = $text context.coordinator.text = $text

View File

@ -250,7 +250,7 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName)
let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in
let state = visibility == newVisibility ? UIMenuElement.State.on : .off let state = visibility == newVisibility ? UIMenuElement.State.on : .off
return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { [unowned self] (_) in return UIAction(title: visibility.displayName, subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName), state: state) { [unowned self] (_) in
self.draft.visibility = visibility self.draft.visibility = visibility
} }
} }
@ -270,9 +270,9 @@ class ComposeHostingController: UIHostingController<ComposeContainerView> {
item.image = UIImage(systemName: "link") item.image = UIImage(systemName: "link")
item.accessibilityLabel = "Federated" item.accessibilityLabel = "Federated"
} }
let instanceSubtitle = "Only \(mastodonController.accountInfo!.instanceURL.host!)"
item.menu = UIMenu(children: [ item.menu = UIMenu(children: [
// todo: iOS 15, action subtitles UIAction(title: "Local-only", subtitle: instanceSubtitle, image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in
UIAction(title: "Local-only", image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in
self.draft.localOnly = true self.draft.localOnly = true
}, },
UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in
@ -474,3 +474,13 @@ extension ComposeHostingController: ComposeDrawingViewControllerDelegate {
dismiss(animated: true) dismiss(animated: true)
} }
} }
fileprivate extension UIAction {
convenience init(title: String, subtitle: String?, image: UIImage?, state: UIAction.State, handler: @escaping UIActionHandler) {
if #available(iOS 15.0, *) {
self.init(title: title, subtitle: subtitle, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: state, handler: handler)
} else {
self.init(title: title, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: state, handler: handler)
}
}
}

View File

@ -10,16 +10,49 @@ import SwiftUI
import Pachyderm import Pachyderm
import Combine import Combine
@propertyWrapper struct OptionalStateObject<T: ObservableObject>: DynamicProperty {
private class Republisher: ObservableObject {
var cancellable: AnyCancellable?
var wrapped: T? {
didSet {
cancellable?.cancel()
cancellable = wrapped?.objectWillChange
.receive(on: RunLoop.main)
.sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}
@StateObject private var republisher = Republisher()
@State private var object: T?
var wrappedValue: T? {
get {
object
}
nonmutating set {
object = newValue
}
}
func update() {
republisher.wrapped = wrappedValue
}
}
struct ComposeView: View { struct ComposeView: View {
@ObservedObject var draft: Draft @ObservedObject var draft: Draft
@EnvironmentObject var mastodonController: MastodonController @EnvironmentObject var mastodonController: MastodonController
@EnvironmentObject var uiState: ComposeUIState @EnvironmentObject var uiState: ComposeUIState
@State private var isPosting = false @OptionalStateObject private var poster: PostService?
@State private var postProgress: Double = 0
@State private var postTotalProgress: Double = 0
@State private var isShowingPostErrorAlert = false @State private var isShowingPostErrorAlert = false
@State private var postError: PostError? @State private var postError: PostService.Error?
private var isPosting: Bool {
poster != nil
}
private let stackPadding: CGFloat = 8 private let stackPadding: CGFloat = 8
@ -58,9 +91,9 @@ struct ComposeView: View {
} }
} }
if postProgress > 0 { if let poster = poster {
// can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149 // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
WrappedProgressView(value: postProgress, total: postTotalProgress) WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
} }
autocompleteSuggestions autocompleteSuggestions
@ -123,6 +156,7 @@ struct ComposeView: View {
// the list rows provide their own padding, so we cancel out the extra spacing from the VStack // the list rows provide their own padding, so we cancel out the extra spacing from the VStack
.padding([.top, .bottom], -8) .padding([.top, .bottom], -8)
} }
.disabled(isPosting)
.padding(stackPadding) .padding(stackPadding)
.padding(.bottom, uiState.autocompleteState != nil ? 46 : nil) .padding(.bottom, uiState.autocompleteState != nil ? 46 : nil)
} }
@ -147,7 +181,11 @@ struct ComposeView: View {
} }
private var postButton: some View { private var postButton: some View {
Button(action: self.postStatus) { Button {
Task {
await self.postStatus()
}
} label: {
Text("Post") Text("Post")
} }
.disabled(!postButtonEnabled) .disabled(!postButtonEnabled)
@ -184,177 +222,31 @@ struct ComposeView: View {
]) ])
} }
private func postStatus() { private func postStatus() async {
guard draft.hasContent else { return } guard !isPosting,
draft.hasContent else {
isPosting = true
// save before posting, so if a crash occurs during network request, the status won't be lost
DraftsManager.save()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
let sensitive = contentWarning != nil
// 2 steps (request data, then upload) for each attachment
postTotalProgress = Double(2 + (draft.attachments.count * 2))
postProgress = 1
uploadAttachments { (result) in
switch result {
case let .failure(error):
self.isShowingPostErrorAlert = true
self.postError = error
self.postProgress = 0
self.postTotalProgress = 0
self.isPosting = false
case let .success(uploadedAttachments):
let request = Client.createStatus(text: draft.textForPosting(on: mastodonController.instanceFeatures),
contentType: Preferences.shared.statusContentType,
inReplyTo: draft.inReplyToID,
media: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
language: nil,
pollOptions: draft.poll?.options.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.instanceType == .hometown ? draft.localOnly : nil)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
self.isShowingPostErrorAlert = true
self.postError = error
case .success(_, _):
self.postProgress += 1
DraftsManager.shared.remove(self.draft)
// wait .25 seconds so the user can see the progress bar has completed
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) {
self.uiState.delegate?.dismissCompose(mode: .post)
}
}
}
}
}
}
private func uploadAttachments(_ completion: @escaping (Result<[Attachment], AttachmentUploadError>) -> Void) {
let group = DispatchGroup()
var attachmentDataResults = [Result<(Data, String), CompositionAttachmentData.Error>?]()
for (index, compAttachment) in draft.attachments.enumerated() {
group.enter()
attachmentDataResults.append(nil)
compAttachment.data.getData { (result) in
postProgress += 1
attachmentDataResults[index] = result
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) {
var anyFailed = false
var uploadedAttachments = [Result<Attachment, Error>?]()
// Mastodon does not respect the order of the `media_ids` parameter in the create post request,
// it determines attachment order by which was uploaded first. Since the upload attachment request
// does not include any timestamp data, and requests may arrive at the server out-of-order,
// attachments need to be uploaded serially in order to ensure the order of attachments in the
// posted status reflects order the user set.
// Pleroma does respect the order of the `media_ids` parameter.
let datas: [(Data, String)]
do {
datas = try attachmentDataResults.map { try $0!.get() }
} catch {
completion(.failure(AttachmentUploadError(errors: [error])))
return return
} }
for (index, (data, mimeType)) in datas.enumerated() { let poster = PostService(mastodonController: mastodonController, draft: draft)
group.enter() self.poster = poster
let compAttachment = draft.attachments[index] do {
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file") try await poster.post()
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
self.mastodonController.run(request) { (response) in
switch response {
case let .failure(error):
uploadedAttachments.append(.failure(error))
anyFailed = true
case let .success(attachment, _): // wait .25 seconds so the user can see the progress bar has completed
self.postProgress += 1 try? await Task.sleep(nanoseconds: 250_000_000)
uploadedAttachments.append(.success(attachment))
uiState.delegate?.dismissCompose(mode: .post)
} catch let error as PostService.Error {
self.isShowingPostErrorAlert = true
self.postError = error
} catch {
fatalError("Unreachable")
} }
group.leave() self.poster = nil
}
group.wait()
}
if anyFailed {
let errors = uploadedAttachments.map { (result) -> Error? in
if case let .failure(error) = result {
return error
} else {
return nil
}
}
completion(.failure(AttachmentUploadError(errors: errors)))
} else {
let uploadedAttachments = uploadedAttachments.map {
try! $0!.get()
}
completion(.success(uploadedAttachments))
}
}
}
}
fileprivate protocol PostError: LocalizedError {}
extension PostError {
var localizedDescription: String {
if let self = self as? Client.Error {
return self.localizedDescription
} else if let self = self as? AttachmentUploadError {
return self.localizedDescription
} else {
return "Unknown Error"
}
}
}
extension Client.Error: PostError {}
fileprivate struct AttachmentUploadError: PostError {
let errors: [Error?]
var localizedDescription: String {
return errors.enumerated().compactMap { (index, error) -> String? in
guard let error = error else { return nil }
let description: String
// need to downcast to use more specific localizedDescription impl from Pachyderm
if let error = error as? Client.Error {
description = error.localizedDescription
} else {
description = error.localizedDescription
}
return "Attachment \(index + 1): \(description)"
}.joined(separator: ",\n")
} }
} }

View File

@ -31,6 +31,7 @@ class ConversationTableViewController: EnhancedTableViewController {
let mainStatusID: String let mainStatusID: String
let mainStatusState: StatusState let mainStatusState: StatusState
var statusIDToScrollToOnLoad: String
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>! private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
var showStatusesAutomatically = false var showStatusesAutomatically = false
@ -41,6 +42,7 @@ class ConversationTableViewController: EnhancedTableViewController {
init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) { init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) {
self.mainStatusID = mainStatusID self.mainStatusID = mainStatusID
self.mainStatusState = state self.mainStatusState = state
self.statusIDToScrollToOnLoad = mainStatusID
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(style: .plain) super.init(style: .plain)
@ -52,14 +54,6 @@ class ConversationTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
guard let persistentContainer = mastodonController?.persistentContainer else { return }
let snapshot = dataSource.snapshot()
for case let .status(id: id, state: _) in snapshot.itemIdentifiers {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -123,54 +117,59 @@ class ConversationTableViewController: EnhancedTableViewController {
return cell return cell
case let .expandThread(childThreads: childThreads): case let .expandThread(childThreads: childThreads, inline: inline):
let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell
cell.updateUI(childThreads: childThreads) cell.updateUI(childThreads: childThreads, inline: inline)
return cell return cell
} }
}) })
if #available(iOS 15.0, *) {
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
visibilityBarButtonItem.isSelected = showStatusesAutomatically
} else {
let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage let initialImage = showStatusesAutomatically ? ConversationTableViewController.hidePostsImage : ConversationTableViewController.showPostsImage
visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed)) visibilityBarButtonItem = UIBarButtonItem(image: initialImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
}
navigationItem.rightBarButtonItem = visibilityBarButtonItem navigationItem.rightBarButtonItem = visibilityBarButtonItem
// disable transparent background when scroll to top because it looks weird when items earlier in the thread load in
// (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar)
let appearance = UINavigationBarAppearance()
appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance
loadMainStatus() Task {
await loadMainStatus()
}
} }
private func loadMainStatus() { @MainActor
private func loadMainStatus() async {
guard loadingState == .unloaded else { return } guard loadingState == .unloaded else { return }
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) { if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
self.mainStatusLoaded(mainStatus) await mainStatusLoaded(mainStatus)
} else { } else {
loadingState = .loadingMain loadingState = .loadingMain
let request = Client.getStatus(id: mainStatusID) let req = Client.getStatus(id: mainStatusID)
mastodonController.run(request) { (response) in do {
switch response { let (status, _) = try await mastodonController.run(req)
case let .success(status, _): let statusMO = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
let viewContext = self.mastodonController.persistentContainer.viewContext await mainStatusLoaded(statusMO)
self.mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false, context: viewContext) { (statusMO) in } catch {
self.mainStatusLoaded(statusMO) let error = error as! Client.Error
} loadingState = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in
case let .failure(error):
DispatchQueue.main.async {
self.loadingState = .unloaded
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self?.loadMainStatus() await self?.loadMainStatus()
}
self.showToast(configuration: config, animated: true)
}
} }
showToast(configuration: config, animated: true)
return
} }
} }
} }
private func mainStatusLoaded(_ mainStatus: StatusMO) { private func mainStatusLoaded(_ mainStatus: StatusMO) async {
mainStatus.incrementReferenceCount()
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
@ -180,10 +179,11 @@ class ConversationTableViewController: EnhancedTableViewController {
loadingState = .loadedMain loadingState = .loadedMain
loadContext(for: mainStatus) await loadContext(for: mainStatus)
} }
private func loadContext(for mainStatus: StatusMO) { @MainActor
private func loadContext(for mainStatus: StatusMO) async {
guard loadingState == .loadedMain else { return } guard loadingState == .loadedMain else { return }
loadingState = .loadingContext loadingState = .loadingContext
@ -193,32 +193,26 @@ class ConversationTableViewController: EnhancedTableViewController {
// todo: it would be nice to cache these contexts // todo: it would be nice to cache these contexts
let request = Status.getContext(mainStatusID) let request = Status.getContext(mainStatusID)
mastodonController.run(request) { response in do {
switch response { let (context, _) = try await mastodonController.run(request)
case let .success(context, _):
let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors) let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) } let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
// todo: should this really be blindly adding all the descendants? // todo: should this really be blindly adding all the descendants?
self.mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) { await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
DispatchQueue.main.async {
self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs) self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs)
}
}
case let .failure(error): } catch {
DispatchQueue.main.async { let error = error as! Client.Error
self.loadingState = .loadedMain self.loadingState = .loadedMain
let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
self?.loadContext(for: mainStatus) await self?.loadContext(for: mainStatus)
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
} }
}
}
private func contextLoaded(mainStatus: StatusMO, context: ConversationContext, parentIDs: [String]) { private func contextLoaded(mainStatus: StatusMO, context: ConversationContext, parentIDs: [String]) {
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
@ -240,10 +234,19 @@ class ConversationTableViewController: EnhancedTableViewController {
} }
self.dataSource.apply(snapshot, animatingDifferences: false) { self.dataSource.apply(snapshot, animatingDifferences: false) {
// ensure that the main status is on-screen after newly loaded statuses are added let item: Item
let position: UITableView.ScrollPosition
if self.statusIDToScrollToOnLoad == self.mainStatusID {
item = mainStatusItem
position = .middle
} else {
item = Item.status(id: self.statusIDToScrollToOnLoad, state: .unknown)
position = .top
}
// ensure that the status is on-screen after newly loaded statuses are added
// todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)? // todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)?
if let indexPath = self.dataSource.indexPath(for: mainStatusItem) { if let indexPath = self.dataSource.indexPath(for: item) {
self.tableView.scrollToRow(at: indexPath, at: .middle, animated: false) self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
} }
} }
@ -324,8 +327,10 @@ class ConversationTableViewController: EnhancedTableViewController {
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id }) let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
if sameAuthorStatuses.count == 1 { if sameAuthorStatuses.count == 1 {
next = sameAuthorStatuses[0] next = sameAuthorStatuses[0]
let nonSameAuthorChildren = currentNode.children.filter { $0.status.id != sameAuthorStatuses[0].status.id }
snapshot.appendItems([.expandThread(childThreads: nonSameAuthorChildren, inline: true)])
} else { } else {
snapshot.appendItems([.expandThread(childThreads: currentNode.children)]) snapshot.appendItems([.expandThread(childThreads: currentNode.children, inline: false)])
break break
} }
} }
@ -350,9 +355,11 @@ class ConversationTableViewController: EnhancedTableViewController {
// MARK: - Table view delegate // MARK: - Table view delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if case .expandThread = dataSource.itemIdentifier(for: indexPath), if case let .expandThread(childThreads: childThreads, inline: _) = dataSource.itemIdentifier(for: indexPath),
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
self.selected(status: id, state: state) let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
show(conv)
} else { } else {
super.tableView(tableView, didSelectRowAt: indexPath) super.tableView(tableView, didSelectRowAt: indexPath)
} }
@ -409,14 +416,14 @@ extension ConversationTableViewController {
} }
enum Item: Hashable { enum Item: Hashable {
case status(id: String, state: StatusState) case status(id: String, state: StatusState)
case expandThread(childThreads: [ConversationNode]) case expandThread(childThreads: [ConversationNode], inline: Bool)
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, state: _), .status(id: b, state: _)):
return a == b return a == b
case let (.expandThread(childThreads: a), .expandThread(childThreads: b)): case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
return zip(a, b).allSatisfy { $0.status.id == $1.status.id } return zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
default: default:
return false return false
} }
@ -427,9 +434,10 @@ extension ConversationTableViewController {
case let .status(id: id, state: _): case let .status(id: id, state: _):
hasher.combine("status") hasher.combine("status")
hasher.combine(id) hasher.combine(id)
case let .expandThread(childThreads: children): case let .expandThread(childThreads: children, inline: inline):
hasher.combine("expandThread") hasher.combine("expandThread")
hasher.combine(children.map(\.status.id)) hasher.combine(children.map(\.status.id))
hasher.combine(inline)
} }
} }
} }
@ -454,6 +462,9 @@ extension ConversationTableViewController: TuskerNavigationDelegate {
} }
} }
extension ConversationTableViewController: MenuActionProvider {
}
extension ConversationTableViewController: StatusTableViewCellDelegate { extension ConversationTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {

View File

@ -10,34 +10,49 @@ import UIKit
class ExpandThreadTableViewCell: UITableViewCell { class ExpandThreadTableViewCell: UITableViewCell {
@IBOutlet weak var stackViewLeadingConstraint: NSLayoutConstraint!
@IBOutlet weak var avatarContainerView: UIView! @IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint! @IBOutlet weak var avatarContainerWidthConstraint: NSLayoutConstraint!
@IBOutlet weak var replyCountLabel: UILabel! @IBOutlet weak var replyCountLabel: UILabel!
var avatarImageViews: [UIImageView] = [] private var threadLinkView: UIView!
private var threadLinkViewFullHeightConstraint: NSLayoutConstraint!
private var threadLinkViewShortHeightConstraint: NSLayoutConstraint!
private var avatarImageViews: [UIImageView] = []
private var avatarRequests: [ImageCache.Request] = [] private var avatarRequests: [ImageCache.Request] = []
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
let prevThreadLinkView = UIView() threadLinkView = UIView()
prevThreadLinkView.translatesAutoresizingMaskIntoConstraints = false threadLinkView.translatesAutoresizingMaskIntoConstraints = false
prevThreadLinkView.backgroundColor = tintColor.withAlphaComponent(0.5) threadLinkView.backgroundColor = tintColor.withAlphaComponent(0.5)
prevThreadLinkView.layer.cornerRadius = 2.5 threadLinkView.layer.cornerRadius = 2.5
prevThreadLinkView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] addSubview(threadLinkView)
contentView.addSubview(prevThreadLinkView) threadLinkViewFullHeightConstraint = threadLinkView.bottomAnchor.constraint(equalTo: bottomAnchor)
threadLinkViewShortHeightConstraint = threadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
prevThreadLinkView.widthAnchor.constraint(equalToConstant: 5), threadLinkView.widthAnchor.constraint(equalToConstant: 5),
prevThreadLinkView.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 16 + 25), threadLinkView.centerXAnchor.constraint(equalTo: leadingAnchor, constant: 25 + 16 /* system spacing */),
prevThreadLinkView.topAnchor.constraint(equalTo: topAnchor), threadLinkView.topAnchor.constraint(equalTo: topAnchor),
prevThreadLinkView.bottomAnchor.constraint(equalTo: avatarContainerView.topAnchor, constant: -2), threadLinkViewFullHeightConstraint,
]) ])
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
} }
func updateUI(childThreads: [ConversationNode]) { func updateUI(childThreads: [ConversationNode], inline: Bool) {
let format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label") stackViewLeadingConstraint.constant = inline ? 50 + 4 : 0
threadLinkView.layer.maskedCorners = inline ? [] : [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
threadLinkViewFullHeightConstraint.isActive = inline
threadLinkViewShortHeightConstraint.isActive = !inline
let format: String
if inline {
format = NSLocalizedString("expand threads inline count", comment: "expnad converstaion threads inline button label")
} else {
format = NSLocalizedString("expand threads count", comment: "expand conversation threads button label")
}
replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count) replyCountLabel.text = String.localizedStringWithFormat(format, childThreads.count)
let accounts = childThreads.map(\.status.account).uniques().prefix(3) let accounts = childThreads.map(\.status.account).uniques().prefix(3)

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17703"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -62,6 +62,7 @@
<outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/> <outlet property="avatarContainerView" destination="eFB-F1-d3A" id="xGo-40-nn7"/>
<outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/> <outlet property="avatarContainerWidthConstraint" destination="tiI-Rj-gjh" id="34n-ev-EKi"/>
<outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/> <outlet property="replyCountLabel" destination="Dcm-ll-GeE" id="E4m-xk-DiQ"/>
<outlet property="stackViewLeadingConstraint" destination="iD5-Av-ORS" id="Try-cG-8uA"/>
</connections> </connections>
<point key="canvasLocation" x="132" y="132"/> <point key="canvasLocation" x="132" y="132"/>
</tableViewCell> </tableViewCell>

View File

@ -123,6 +123,7 @@ extension DraftsTableViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let draft = self.draft(for: indexPath) let draft = self.draft(for: indexPath)
let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: account.id) let activity = UserActivityManager.editDraftActivity(id: draft.id, accountID: account.id)
activity.displaysAuxiliaryScene = true
let provider = NSItemProvider(object: activity) let provider = NSItemProvider(object: activity)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -86,7 +86,9 @@ class AddSavedHashtagViewController: EnhancedTableViewController {
} }
private func selectHashtag(_ hashtag: Hashtag) { private func selectHashtag(_ hashtag: Hashtag) {
SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!) let context = mastodonController.persistentContainer.viewContext
_ = SavedHashtag(hashtag: hashtag, context: context)
try! context.save()
presentingViewController!.dismiss(animated: true) presentingViewController!.dismiss(animated: true)
} }

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Combine import Combine
import Pachyderm import Pachyderm
import CoreData
class ExploreViewController: UIViewController, UICollectionViewDelegate { class ExploreViewController: UIViewController, UICollectionViewDelegate {
@ -134,8 +135,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
private func applyInitialSnapshot() { private func applyInitialSnapshot() {
let account = mastodonController.accountInfo!
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections(Section.allCases.filter { $0 != .discover }) snapshot.appendSections(Section.allCases.filter { $0 != .discover })
snapshot.appendItems([.bookmarks], toSection: .bookmarks) snapshot.appendItems([.bookmarks], toSection: .bookmarks)
@ -144,9 +143,15 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
addDiscoverSection(to: &snapshot) addDiscoverSection(to: &snapshot)
} }
snapshot.appendItems([.addList], toSection: .lists) snapshot.appendItems([.addList], toSection: .lists)
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
snapshot.appendItems(hashtags, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances) let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
snapshot.appendItems(instances, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
@ -190,20 +195,46 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
} }
@MainActor
private func fetchSavedHashtags() -> [SavedHashtag] {
let req = SavedHashtag.fetchRequest()
req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch {
return []
}
}
@MainActor
private func fetchSavedInstances() -> [SavedInstance] {
let req = SavedInstance.fetchRequest()
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch {
return []
}
}
@objc private func savedHashtagsChanged() { @objc private func savedHashtagsChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags)) snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedHashtags))
snapshot.appendItems(SavedDataManager.shared.sortedHashtags(for: account).map { .savedHashtag($0) }, toSection: .savedHashtags) let hashtags = fetchSavedHashtags().map {
Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
snapshot.appendItems(hashtags, toSection: .savedHashtags)
snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags) snapshot.appendItems([.addSavedHashtag], toSection: .savedHashtags)
dataSource.apply(snapshot) dataSource.apply(snapshot)
} }
@objc private func savedInstancesChanged() { @objc private func savedInstancesChanged() {
let account = mastodonController.accountInfo!
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances)) snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .savedInstances))
snapshot.appendItems(SavedDataManager.shared.savedInstances(for: account).map { .savedInstance($0) }, toSection: .savedInstances) let instances = fetchSavedInstances().map {
Item.savedInstance($0.url)
}
snapshot.appendItems(instances, toSection: .savedInstances)
snapshot.appendItems([.findInstance], toSection: .savedInstances) snapshot.appendItems([.findInstance], toSection: .savedInstances)
dataSource.apply(snapshot) dataSource.apply(snapshot)
} }
@ -249,30 +280,40 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
func removeSavedHashtag(_ hashtag: Hashtag) { func removeSavedHashtag(_ hashtag: Hashtag) {
let account = mastodonController.accountInfo! let context = mastodonController.persistentContainer.viewContext
SavedDataManager.shared.remove(hashtag: hashtag, for: account) if let hashtag = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(hashtag)
try! context.save()
}
} }
func removeSavedInstance(_ instanceURL: URL) { func removeSavedInstance(_ instanceURL: URL) {
let account = mastodonController.accountInfo! let context = mastodonController.persistentContainer.viewContext
SavedDataManager.shared.remove(instance: instanceURL, for: account) if let instance = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first {
context.delete(instance)
try! context.save()
}
} }
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let title: String
let handler: UIContextualAction.Handler let handler: UIContextualAction.Handler
switch dataSource.itemIdentifier(for: indexPath) { switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list): case let .list(list):
title = NSLocalizedString("Delete", comment: "delete swipe action title")
handler = { (_, _, completion) in handler = { (_, _, completion) in
self.deleteList(list, completion: completion) self.deleteList(list, completion: completion)
} }
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in handler = { (_, _, completion) in
self.removeSavedHashtag(hashtag) self.removeSavedHashtag(hashtag)
completion(true) completion(true)
} }
case let .savedInstance(url): case let .savedInstance(url):
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in handler = { (_, _, completion) in
self.removeSavedInstance(url) self.removeSavedInstance(url)
completion(true) completion(true)
@ -283,7 +324,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate {
} }
return UISwipeActionsConfiguration(actions: [ return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: NSLocalizedString("Delete", comment: "delete swipe action title"), handler: handler) UIContextualAction(style: .destructive, title: title, handler: handler)
]) ])
} }
@ -533,13 +574,17 @@ extension ExploreViewController: UICollectionViewDragDelegate {
let provider: NSItemProvider let provider: NSItemProvider
switch item { switch item {
case .bookmarks: case .bookmarks:
provider = NSItemProvider(object: UserActivityManager.bookmarksActivity()) let activity = UserActivityManager.bookmarksActivity()
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity)
case let .list(list): case let .list(list):
guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] } guard let activity = UserActivityManager.showTimelineActivity(timeline: .list(id: list.id), accountID: accountID) else { return [] }
activity.displaysAuxiliaryScene = true
provider = NSItemProvider(object: activity) provider = NSItemProvider(object: activity)
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
provider = NSItemProvider(object: hashtag.url as NSURL) provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: accountID) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
} }
case let .savedInstance(url): case let .savedInstance(url):

View File

@ -134,8 +134,10 @@ extension ProfileDirectoryViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension ProfileDirectoryViewController: MenuPreviewProvider { extension ProfileDirectoryViewController: ToastableViewController {
var navigationDelegate: TuskerNavigationDelegate? { self } }
extension ProfileDirectoryViewController: MenuActionProvider {
} }
extension ProfileDirectoryViewController: UICollectionViewDelegate { extension ProfileDirectoryViewController: UICollectionViewDelegate {
@ -181,6 +183,7 @@ extension ProfileDirectoryViewController: UICollectionViewDragDelegate {
} }
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -90,6 +90,7 @@ class TrendingHashtagsViewController: EnhancedTableViewController {
} }
let provider = NSItemProvider(object: hashtag.url as NSURL) let provider = NSItemProvider(object: hashtag.url as NSURL)
if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) { if let activity = UserActivityManager.showTimelineActivity(timeline: .tag(hashtag: hashtag.name), accountID: mastodonController.accountInfo!.id) {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
} }
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
@ -110,6 +111,8 @@ extension TrendingHashtagsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension TrendingHashtagsViewController: MenuPreviewProvider { extension TrendingHashtagsViewController: ToastableViewController {
var navigationDelegate: TuskerNavigationDelegate? { self } }
extension TrendingHashtagsViewController: MenuActionProvider {
} }

View File

@ -104,6 +104,8 @@ extension TrendingLinksViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension TrendingLinksViewController: MenuPreviewProvider { extension TrendingLinksViewController: ToastableViewController {
var navigationDelegate: TuskerNavigationDelegate? { self } }
extension TrendingLinksViewController: MenuActionProvider {
} }

View File

@ -85,6 +85,12 @@ extension TrendingStatusesViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension TrendingStatusesViewController: ToastableViewController {
}
extension TrendingStatusesViewController: MenuActionProvider {
}
extension TrendingStatusesViewController: StatusTableViewCellDelegate { extension TrendingStatusesViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
tableView.beginUpdates() tableView.beginUpdates()

View File

@ -9,6 +9,8 @@
import UIKit import UIKit
protocol FastAccountSwitcherViewControllerDelegate: AnyObject { protocol FastAccountSwitcherViewControllerDelegate: AnyObject {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController)
/// - Parameter point: In the coordinate space of the view to which the pan gesture recognizer is attached.
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool
} }
@ -20,11 +22,13 @@ class FastAccountSwitcherViewController: UIViewController {
@IBOutlet weak var blurContentView: UIView! @IBOutlet weak var blurContentView: UIView!
@IBOutlet weak var accountsStack: UIStackView! @IBOutlet weak var accountsStack: UIStackView!
private var accountViews: [FastSwitchingAccountView] = [] private(set) var accountViews: [FastSwitchingAccountView] = []
private var lastSelectedAccountViewIndex: Int? private var lastSelectedAccountViewIndex: Int?
private var selectionChangedFeedbackGenerator: UIImpactFeedbackGenerator? private var selectionChangedFeedbackGenerator: UIImpactFeedbackGenerator?
private var touchBeganFeedbackWorkItem: DispatchWorkItem? private var touchBeganFeedbackWorkItem: DispatchWorkItem?
var itemOrientation: ItemOrientation = .iconsTrailing
init() { init() {
super.init(nibName: "FastAccountSwitcherViewController", bundle: .main) super.init(nibName: "FastAccountSwitcherViewController", bundle: .main)
} }
@ -51,6 +55,15 @@ class FastAccountSwitcherViewController: UIViewController {
func show() { func show() {
createAccountViews() createAccountViews()
// add after creating account views so that the presenter can align based on them
delegate?.fastAccountSwitcherAddToViewHierarchy(self)
switch itemOrientation {
case .iconsLeading:
accountsStack.alignment = .leading
case .iconsTrailing:
accountsStack.alignment = .trailing
}
view.isHidden = false view.isHidden = false
@ -87,24 +100,35 @@ class FastAccountSwitcherViewController: UIViewController {
} }
func hide(completion: (() -> Void)? = nil) { func hide(completion: (() -> Void)? = nil) {
guard view.superview != nil else {
return
}
lastSelectedAccountViewIndex = nil lastSelectedAccountViewIndex = nil
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) { UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) {
self.view.alpha = 0 self.view.alpha = 0
} completion: { (_) in } completion: { (_) in
// todo: probably remove these two lines
self.view.alpha = 1 self.view.alpha = 1
self.view.isHidden = true self.view.isHidden = true
completion?() completion?()
self.view.removeFromSuperview()
} }
} }
private func createAccountViews() { private func createAccountViews() {
accountsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } accountsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
accountViews = []
let addAccountPlaceholder = FastSwitchingAccountView(orientation: itemOrientation)
accountsStack.addArrangedSubview(addAccountPlaceholder)
accountViews = [
addAccountPlaceholder
]
for account in LocalData.shared.accounts { for account in LocalData.shared.accounts {
let accountView = FastSwitchingAccountView(account: account) let accountView = FastSwitchingAccountView(account: account, orientation: itemOrientation)
accountView.isCurrent = account.id == LocalData.shared.mostRecentAccountID accountView.isCurrent = account.id == LocalData.shared.mostRecentAccountID
accountsStack.addArrangedSubview(accountView) accountsStack.addArrangedSubview(accountView)
accountViews.append(accountView) accountViews.append(accountView)
@ -122,7 +146,17 @@ class FastAccountSwitcherViewController: UIViewController {
} }
private func switchAccount(newIndex: Int, hapticFeedback: Bool = true) { private func switchAccount(newIndex: Int, hapticFeedback: Bool = true) {
let account = LocalData.shared.accounts[newIndex] if newIndex == 0 { // add account placeholder
if hapticFeedback {
selectionChangedFeedbackGenerator?.impactOccurred()
}
selectionChangedFeedbackGenerator = nil
hide() {
(self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
}
} else {
let account = LocalData.shared.accounts[newIndex - 1]
if account.id != LocalData.shared.mostRecentAccountID { if account.id != LocalData.shared.mostRecentAccountID {
if hapticFeedback { if hapticFeedback {
@ -137,6 +171,7 @@ class FastAccountSwitcherViewController: UIViewController {
hide() hide()
} }
} }
}
// MARK: - Interaction // MARK: - Interaction
@ -155,10 +190,9 @@ class FastAccountSwitcherViewController: UIViewController {
handleGestureMoved(to: location) handleGestureMoved(to: location)
case .ended: case .ended:
let location = recognizer.location(in: view)
if let index = lastSelectedAccountViewIndex { if let index = lastSelectedAccountViewIndex {
switchAccount(newIndex: index) switchAccount(newIndex: index)
} else if !(delegate?.fastAccountSwitcher(self, triggerZoneContains: location) ?? false) { } else if !(delegate?.fastAccountSwitcher(self, triggerZoneContains: recognizer.location(in: recognizer.view)) ?? false) {
hide() hide()
} }
@ -240,9 +274,16 @@ class FastAccountSwitcherViewController: UIViewController {
} }
extension FastAccountSwitcherViewController {
enum ItemOrientation {
case iconsLeading
case iconsTrailing
}
}
extension FastAccountSwitcherViewController: UIGestureRecognizerDelegate { extension FastAccountSwitcherViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let point = gestureRecognizer.location(in: view) let point = gestureRecognizer.location(in: gestureRecognizer.view)
return delegate?.fastAccountSwitcher(self, triggerZoneContains: point) ?? false return delegate?.fastAccountSwitcher(self, triggerZoneContains: point) ?? false
} }
} }

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
@ -12,7 +12,6 @@
<connections> <connections>
<outlet property="accountsStack" destination="lYU-Bb-3Wi" id="Dxs-ta-ORu"/> <outlet property="accountsStack" destination="lYU-Bb-3Wi" id="Dxs-ta-ORu"/>
<outlet property="blurContentView" destination="1Gd-Da-Vab" id="JqT-uq-1o2"/> <outlet property="blurContentView" destination="1Gd-Da-Vab" id="JqT-uq-1o2"/>
<outlet property="dimmingView" destination="Lul-oI-bZ7" id="JhP-ZX-8fb"/>
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/> <outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
</connections> </connections>
</placeholder> </placeholder>
@ -21,10 +20,6 @@
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/> <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<view alpha="0.25" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Lul-oI-bZ7">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5fd-Ni-Owc"> <visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5fd-Ni-Owc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/> <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="1Gd-Da-Vab"> <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="1Gd-Da-Vab">
@ -38,7 +33,7 @@
<constraints> <constraints>
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="top" secondItem="1Gd-Da-Vab" secondAttribute="topMargin" placeholder="YES" id="KQs-d5-U3f"/> <constraint firstItem="lYU-Bb-3Wi" firstAttribute="top" secondItem="1Gd-Da-Vab" secondAttribute="topMargin" placeholder="YES" id="KQs-d5-U3f"/>
<constraint firstAttribute="trailing" secondItem="lYU-Bb-3Wi" secondAttribute="trailingMargin" constant="8" id="UZh-xR-XVt"/> <constraint firstAttribute="trailing" secondItem="lYU-Bb-3Wi" secondAttribute="trailingMargin" constant="8" id="UZh-xR-XVt"/>
<constraint firstAttribute="bottomMargin" secondItem="lYU-Bb-3Wi" secondAttribute="bottom" id="j6f-r5-NNI"/> <constraint firstAttribute="bottomMargin" secondItem="lYU-Bb-3Wi" secondAttribute="bottom" placeholder="YES" id="j6f-r5-NNI"/>
<constraint firstItem="lYU-Bb-3Wi" firstAttribute="leading" secondItem="1Gd-Da-Vab" secondAttribute="leading" constant="8" id="sae-ga-MGE"/> <constraint firstItem="lYU-Bb-3Wi" firstAttribute="leading" secondItem="1Gd-Da-Vab" secondAttribute="leading" constant="8" id="sae-ga-MGE"/>
</constraints> </constraints>
</view> </view>
@ -48,20 +43,11 @@
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/> <viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
<gestureRecognizers/> <gestureRecognizers/>
<constraints> <constraints>
<constraint firstAttribute="trailing" secondItem="Lul-oI-bZ7" secondAttribute="trailing" id="9Fp-IG-O9W"/>
<constraint firstAttribute="trailing" secondItem="5fd-Ni-Owc" secondAttribute="trailing" id="c27-P9-lLK"/> <constraint firstAttribute="trailing" secondItem="5fd-Ni-Owc" secondAttribute="trailing" id="c27-P9-lLK"/>
<constraint firstAttribute="bottom" secondItem="Lul-oI-bZ7" secondAttribute="bottom" id="o6y-tG-MwH"/>
<constraint firstItem="5fd-Ni-Owc" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="phf-PC-bdH"/> <constraint firstItem="5fd-Ni-Owc" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="phf-PC-bdH"/>
<constraint firstItem="5fd-Ni-Owc" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="rz7-cQ-PIC"/> <constraint firstItem="5fd-Ni-Owc" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="rz7-cQ-PIC"/>
<constraint firstAttribute="bottom" secondItem="5fd-Ni-Owc" secondAttribute="bottom" id="sHl-iD-kGi"/> <constraint firstAttribute="bottom" secondItem="5fd-Ni-Owc" secondAttribute="bottom" id="sHl-iD-kGi"/>
<constraint firstItem="Lul-oI-bZ7" firstAttribute="leading" secondItem="i5M-Pr-FkT" secondAttribute="leading" id="tfE-Xr-YBo"/>
<constraint firstItem="Lul-oI-bZ7" firstAttribute="top" secondItem="i5M-Pr-FkT" secondAttribute="top" id="ua7-DO-kdp"/>
</constraints> </constraints>
<variation key="default">
<mask key="subviews">
<exclude reference="Lul-oI-bZ7"/>
</mask>
</variation>
<point key="canvasLocation" x="140.57971014492756" y="144.64285714285714"/> <point key="canvasLocation" x="140.57971014492756" y="144.64285714285714"/>
</view> </view>
</objects> </objects>

View File

@ -10,8 +10,6 @@ import UIKit
class FastSwitchingAccountView: UIView { class FastSwitchingAccountView: UIView {
let account: LocalData.UserAccountInfo
private static let selectedColor = UIColor { (traits) in private static let selectedColor = UIColor { (traits) in
if traits.userInterfaceStyle == .dark { if traits.userInterfaceStyle == .dark {
return UIColor(hue: 211 / 360, saturation: 85 / 100, brightness: 100 / 100, alpha: 1) return UIColor(hue: 211 / 360, saturation: 85 / 100, brightness: 100 / 100, alpha: 1)
@ -37,23 +35,38 @@ class FastSwitchingAccountView: UIView {
} }
} }
private let orientation: FastAccountSwitcherViewController.ItemOrientation
private let usernameLabel = UILabel() private let usernameLabel = UILabel()
private let instanceLabel = UILabel() private let instanceLabel = UILabel()
private let avatarImageView = UIImageView()
private var avatarRequest: ImageCache.Request? private var avatarRequest: ImageCache.Request?
init(account: LocalData.UserAccountInfo) { init(account: LocalData.UserAccountInfo, orientation: FastAccountSwitcherViewController.ItemOrientation) {
self.account = account self.orientation = orientation
super.init(frame: .zero) super.init(frame: .zero)
commonInit()
setupAccount(account: account)
}
init(orientation: FastAccountSwitcherViewController.ItemOrientation) {
self.orientation = orientation
super.init(frame: .zero)
commonInit()
setupPlaceholder()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func commonInit() {
usernameLabel.textColor = .white usernameLabel.textColor = .white
usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline), size: 0) usernameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .headline), size: 0)
usernameLabel.text = account.username
instanceLabel.textColor = .white instanceLabel.textColor = .white
instanceLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .subheadline), size: 0) instanceLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .subheadline), size: 0)
instanceLabel.text = account.instanceURL.host!
let stackView = UIStackView(arrangedSubviews: [ let stackView = UIStackView(arrangedSubviews: [
usernameLabel, usernameLabel,
@ -61,45 +74,66 @@ class FastSwitchingAccountView: UIView {
]) ])
stackView.translatesAutoresizingMaskIntoConstraints = false stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical stackView.axis = .vertical
stackView.alignment = .trailing
addSubview(stackView) addSubview(stackView)
let avatarImageView = UIImageView()
avatarImageView.translatesAutoresizingMaskIntoConstraints = false avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.layer.masksToBounds = true avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 40 avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 40
avatarImageView.image = UIImage(systemName: Preferences.shared.avatarStyle == .circle ? "person.crop.circle" : "person.crop.square") avatarImageView.image = UIImage(systemName: Preferences.shared.avatarStyle == .circle ? "person.crop.circle" : "person.crop.square")
avatarImageView.contentMode = .scaleAspectFit avatarImageView.contentMode = .scaleAspectFill
addSubview(avatarImageView) addSubview(avatarImageView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
avatarImageView.topAnchor.constraint(equalTo: topAnchor, constant: 8), avatarImageView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
avatarImageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), avatarImageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
avatarImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: 40), avatarImageView.widthAnchor.constraint(equalToConstant: 40),
avatarImageView.heightAnchor.constraint(equalTo: avatarImageView.widthAnchor), avatarImageView.heightAnchor.constraint(equalTo: avatarImageView.widthAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
stackView.trailingAnchor.constraint(equalTo: avatarImageView.leadingAnchor, constant: -8),
stackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor), stackView.centerYAnchor.constraint(equalTo: avatarImageView.centerYAnchor),
]) ])
switch orientation {
case .iconsLeading:
stackView.alignment = .leading
NSLayoutConstraint.activate([
avatarImageView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
])
case .iconsTrailing:
stackView.alignment = .trailing
NSLayoutConstraint.activate([
avatarImageView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
stackView.trailingAnchor.constraint(equalTo: avatarImageView.leadingAnchor, constant: -8),
])
}
updateLabelColors()
}
private func setupAccount(account: LocalData.UserAccountInfo) {
usernameLabel.text = account.username
instanceLabel.text = account.instanceURL.host!
let controller = MastodonController.getForAccount(account) let controller = MastodonController.getForAccount(account)
controller.getOwnAccount { [weak self] (result) in controller.getOwnAccount { [weak self] (result) in
guard let self = self, guard let self = self,
case let .success(account) = result, case let .success(account) = result,
let avatar = account.avatar else { return } let avatar = account.avatar else { return }
self.avatarRequest = ImageCache.avatars.get(avatar) { [weak avatarImageView] (_, image) in self.avatarRequest = ImageCache.avatars.get(avatar) { [weak self] (_, image) in
guard let avatarImageView = avatarImageView, let image = image else { return } guard let self = self, let image = image else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
avatarImageView.image = image self.avatarImageView.image = image
} }
} }
} }
} }
required init?(coder: NSCoder) { private func setupPlaceholder() {
fatalError("init(coder:) has not been implemented") usernameLabel.text = "Add Account"
instanceLabel.isHidden = true
avatarImageView.image = UIImage(systemName: "plus")
} }
private func updateLabelColors() { private func updateLabelColors() {
@ -113,6 +147,7 @@ class FastSwitchingAccountView: UIView {
} }
usernameLabel.textColor = color usernameLabel.textColor = color
instanceLabel.textColor = color instanceLabel.textColor = color
avatarImageView.tintColor = color
} }
} }

View File

@ -176,3 +176,9 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
extension EditListAccountsViewController: TuskerNavigationDelegate { extension EditListAccountsViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension EditListAccountsViewController: ToastableViewController {
}
extension EditListAccountsViewController: MenuActionProvider {
}

View File

@ -86,8 +86,14 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
} }
func performSearch(query: String) { func performSearch(query: String) {
loadViewIfNeeded()
root.performSearch(query: query) root.performSearch(query: query)
} }
func presentPreferences(completion: (() -> Void)?) {
loadViewIfNeeded()
root.presentPreferences(completion: completion)
}
} }
extension AccountSwitchingContainerViewController: BackgroundableViewController { extension AccountSwitchingContainerViewController: BackgroundableViewController {

View File

@ -0,0 +1,86 @@
//
// MainSidebarMyProfileCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 4/30/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class MainSidebarMyProfileCollectionViewCell: UICollectionViewListCell {
private var verticalImageInset: CGFloat {
if UIDevice.current.userInterfaceIdiom == .mac {
return (28 - avatarImageSize) / 2
} else {
return (44 - avatarImageSize) / 2
}
}
private var avatarImageSize: CGFloat {
if UIDevice.current.userInterfaceIdiom == .mac {
return 20
} else {
return 28
}
}
override init(frame: CGRect) {
super.init(frame: frame)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateUI(item: MainSidebarViewController.Item, account: LocalData.UserAccountInfo) async {
var config = defaultContentConfiguration()
config.text = item.title
config.image = UIImage(systemName: item.imageName!)
self.contentConfiguration = config
if UIDevice.current.userInterfaceIdiom != .mac {
let indicator = FastAccountSwitcherIndicatorView()
// need to explicitly set the frame to get it vertically centered
indicator.frame = CGRect(origin: .zero, size: indicator.intrinsicContentSize)
accessories = [
.customView(configuration: .init(customView: indicator, placement: .trailing()))
]
}
let mastodonController = MastodonController.getForAccount(account)
guard let account = try? await mastodonController.getOwnAccount(),
let avatar = account.avatar else {
return
}
_ = ImageCache.avatars.get(avatar, loadOriginal: false) { [weak self] _, image in
guard let self = self,
let image = image else {
return
}
DispatchQueue.main.async {
guard var config = self.contentConfiguration as? UIListContentConfiguration else {
return
}
config.image = image
config.directionalLayoutMargins.top = self.verticalImageInset
config.directionalLayoutMargins.bottom = self.verticalImageInset
config.imageProperties.maximumSize = CGSize(width: self.avatarImageSize, height: self.avatarImageSize)
config.imageProperties.reservedLayoutSize = CGSize(width: UIListContentConfiguration.ImageProperties.standardDimension, height: 0)
config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * self.avatarImageSize
self.contentConfiguration = config
}
}
}
@objc private func preferencesChanged() {
guard var config = self.contentConfiguration as? UIListContentConfiguration else {
return
}
config.imageProperties.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * avatarImageSize
self.contentConfiguration = contentConfiguration
}
}

View File

@ -19,6 +19,11 @@ class MainSidebarViewController: UIViewController {
private weak var mastodonController: MastodonController! private weak var mastodonController: MastodonController!
weak var sidebarDelegate: MainSidebarViewControllerDelegate? weak var sidebarDelegate: MainSidebarViewControllerDelegate?
var onViewDidLoad: (() -> Void)? = nil {
willSet {
precondition(onViewDidLoad == nil)
}
}
private var collectionView: UICollectionView! private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -95,6 +100,8 @@ class MainSidebarViewController: UIViewController {
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedHashtags), name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
onViewDidLoad?()
} }
func select(item: Item, animated: Bool) { func select(item: Item, animated: Bool) {
@ -115,6 +122,12 @@ class MainSidebarViewController: UIViewController {
cell.contentConfiguration = config cell.contentConfiguration = config
} }
let myProfileCell = UICollectionView.CellRegistration<MainSidebarMyProfileCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
Task {
await cell.updateUI(item: item, account: self.mastodonController.accountInfo!)
}
}
let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in let outlineHeaderCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { (cell, indexPath, item) in
var config = cell.defaultContentConfiguration() var config = cell.defaultContentConfiguration()
config.attributedText = NSAttributedString(string: item.title, attributes: [ config.attributedText = NSAttributedString(string: item.title, attributes: [
@ -125,7 +138,9 @@ class MainSidebarViewController: UIViewController {
} }
return UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in return UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView, cellProvider: { (collectionView, indexPath, item) -> UICollectionViewCell? in
if item.hasChildren { if case .tab(.myProfile) = item {
return collectionView.dequeueConfiguredReusableCell(using: myProfileCell, for: indexPath, item: item)
} else if item.hasChildren {
return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCell, for: indexPath, item: item) return collectionView.dequeueConfiguredReusableCell(using: outlineHeaderCell, for: indexPath, item: item)
} else { } else {
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item) return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
@ -210,14 +225,38 @@ class MainSidebarViewController: UIViewController {
} }
} }
@MainActor
private func fetchSavedHashtags() -> [SavedHashtag] {
let req = SavedHashtag.fetchRequest()
req.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true, selector: #selector(NSString.localizedCompare(_:)))]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch {
return []
}
}
@MainActor
private func fetchSavedInstances() -> [SavedInstance] {
let req = SavedInstance.fetchRequest()
req.sortDescriptors = [NSSortDescriptor(key: "url.host", ascending: true)]
do {
return try mastodonController.persistentContainer.viewContext.fetch(req)
} catch {
return []
}
}
@objc private func reloadSavedHashtags() { @objc private func reloadSavedHashtags() {
let selected = collectionView.indexPathsForSelectedItems?.first let selected = collectionView.indexPathsForSelectedItems?.first
var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>() var hashtagsSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
hashtagsSnapshot.append([.savedHashtagsHeader]) hashtagsSnapshot.append([.savedHashtagsHeader])
hashtagsSnapshot.expand([.savedHashtagsHeader]) hashtagsSnapshot.expand([.savedHashtagsHeader])
let sortedHashtags = SavedDataManager.shared.sortedHashtags(for: mastodonController.accountInfo!) let hashtags = fetchSavedHashtags().map {
hashtagsSnapshot.append(sortedHashtags.map { .savedHashtag($0) }, to: .savedHashtagsHeader) Item.savedHashtag(Hashtag(name: $0.name, url: $0.url))
}
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, animatingDifferences: false) {
if let selected = selected { if let selected = selected {
@ -232,8 +271,10 @@ class MainSidebarViewController: UIViewController {
var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>() var instancesSnapshot = NSDiffableDataSourceSectionSnapshot<Item>()
instancesSnapshot.append([.savedInstancesHeader]) instancesSnapshot.append([.savedInstancesHeader])
instancesSnapshot.expand([.savedInstancesHeader]) instancesSnapshot.expand([.savedInstancesHeader])
let sortedInstances = SavedDataManager.shared.savedInstances(for: mastodonController.accountInfo!) let instances = fetchSavedInstances().map {
instancesSnapshot.append(sortedInstances.map { .savedInstance($0) }, to: .savedInstancesHeader) Item.savedInstance($0.url)
}
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, animatingDifferences: false) {
if let selected = selected { if let selected = selected {
@ -322,6 +363,14 @@ class MainSidebarViewController: UIViewController {
} }
} }
func myProfileCell() -> UICollectionViewCell? {
guard let indexPath = dataSource.indexPath(for: .tab(.myProfile)),
let item = collectionView.cellForItem(at: indexPath) else {
return nil
}
return item
}
} }
extension MainSidebarViewController { extension MainSidebarViewController {
@ -506,6 +555,12 @@ extension MainSidebarViewController: UICollectionViewDelegate {
let activity = userActivityForItem(item) else { let activity = userActivityForItem(item) else {
return nil return nil
} }
if case .tab(.myProfile) = item,
// only disable context menu on long-press, to allow fast account switching
collectionView.contextMenuInteraction?.menuAppearance == .rich {
return nil
}
activity.displaysAuxiliaryScene = true
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) in
return UIMenu(children: [ return UIMenu(children: [
UIWindowScene.ActivationAction({ action in UIWindowScene.ActivationAction({ action in
@ -522,7 +577,11 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
let activity = userActivityForItem(item) else { let activity = userActivityForItem(item) else {
return [] return []
} }
if case .tab(.myProfile) = item {
return []
}
activity.displaysAuxiliaryScene = true
let provider = NSItemProvider(object: activity) let provider = NSItemProvider(object: activity)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }
@ -531,6 +590,7 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) { func didSaveInstance(url: URL) {
dismiss(animated: true) { dismiss(animated: true) {
self.select(item: .savedInstance(url), animated: true)
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url)) self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
} }
} }

View File

@ -13,6 +13,7 @@ class MainSplitViewController: UISplitViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
private var sidebar: MainSidebarViewController! private var sidebar: MainSidebarViewController!
private var fastAccountSwitcher: FastAccountSwitcherViewController?
// Keep track of navigation stacks per-item so that we can only ever use a single navigation controller // Keep track of navigation stacks per-item so that we can only ever use a single navigation controller
private var navigationStacks: [MainSidebarViewController.Item: [UIViewController]] = [:] private var navigationStacks: [MainSidebarViewController.Item: [UIViewController]] = [:]
@ -23,14 +24,6 @@ class MainSplitViewController: UISplitViewController {
viewController(for: .secondary) as? UINavigationController viewController(for: .secondary) as? UINavigationController
} }
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
} else {
return .all
}
}
init(mastodonController: MastodonController) { init(mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -60,6 +53,23 @@ class MainSplitViewController: UISplitViewController {
select(item: .tab(.timelines)) select(item: .tab(.timelines))
} }
if UIDevice.current.userInterfaceIdiom != .mac {
let switcher = FastAccountSwitcherViewController()
fastAccountSwitcher = switcher
switcher.itemOrientation = .iconsLeading
switcher.view.translatesAutoresizingMaskIntoConstraints = false
switcher.delegate = self
// accessing .view unconditionally loads the view, which we don't want to happen
// because the sidebar view not being loaded is how we know not to transfer nav state
// in splitViewControllerDidCollapse on devices where the sidebar is never shown
sidebar.onViewDidLoad = { [unowned self] in
self.sidebar.view.addGestureRecognizer(switcher.createSwitcherGesture())
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(sidebarTapped))
tapRecognizer.cancelsTouchesInView = false
self.sidebar.view.addGestureRecognizer(tapRecognizer)
}
}
tabBarViewController = MainTabBarViewController(mastodonController: mastodonController) tabBarViewController = MainTabBarViewController(mastodonController: mastodonController)
setViewController(tabBarViewController, for: .compact) setViewController(tabBarViewController, for: .compact)
@ -101,6 +111,10 @@ class MainSplitViewController: UISplitViewController {
select(item: item) select(item: item)
} }
@objc private func sidebarTapped() {
fastAccountSwitcher?.hide()
}
} }
extension MainSplitViewController: UISplitViewControllerDelegate { extension MainSplitViewController: UISplitViewControllerDelegate {
@ -441,6 +455,10 @@ extension MainSplitViewController: TuskerRootViewController {
searchViewController.searchController.searchBar.text = query searchViewController.searchController.searchBar.text = query
searchViewController.resultsController.performSearch(query: query) searchViewController.resultsController.performSearch(query: query)
} }
func presentPreferences(completion: (() -> Void)?) {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
}
} }
extension MainSplitViewController: BackgroundableViewController { extension MainSplitViewController: BackgroundableViewController {
@ -455,3 +473,27 @@ extension MainSplitViewController: BackgroundableViewController {
} }
} }
} }
extension MainSplitViewController: FastAccountSwitcherViewControllerDelegate {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
view.addSubview(fastAccountSwitcher.view)
let currentAccount = fastAccountSwitcher.accountViews.first(where: \.isCurrent)!
let myProfileCell = sidebar.myProfileCell()!
NSLayoutConstraint.activate([
currentAccount.centerYAnchor.constraint(equalTo: myProfileCell.centerYAnchor),
fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: sidebar.view.trailingAnchor),
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
guard !isCollapsed,
let cell = sidebar.myProfileCell() else {
return false
}
let cellRect = cell.convert(cell.bounds, to: sidebar.view)
return cellRect.contains(point)
}
}

View File

@ -43,6 +43,8 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
stateRestorationLogger.info("MainTabBarViewController: viewDidLoad, selectedIndex=\(self.selectedIndex, privacy: .public)")
self.delegate = self self.delegate = self
composePlaceholder = UIViewController() composePlaceholder = UIViewController()
@ -60,13 +62,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
fastAccountSwitcher = FastAccountSwitcherViewController() fastAccountSwitcher = FastAccountSwitcherViewController()
fastAccountSwitcher.delegate = self fastAccountSwitcher.delegate = self
fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false fastAccountSwitcher.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(fastAccountSwitcher.view)
NSLayoutConstraint.activate([
fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
])
tabBar.addGestureRecognizer(fastAccountSwitcher.createSwitcherGesture()) tabBar.addGestureRecognizer(fastAccountSwitcher.createSwitcherGesture())
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tabBarTapped)) let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(tabBarTapped))
@ -77,10 +72,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
fastSwitcherIndicator = FastAccountSwitcherIndicatorView() fastSwitcherIndicator = FastAccountSwitcherIndicatorView()
fastSwitcherIndicator.translatesAutoresizingMaskIntoConstraints = false fastSwitcherIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(fastSwitcherIndicator) view.addSubview(fastSwitcherIndicator)
NSLayoutConstraint.activate([
fastSwitcherIndicator.widthAnchor.constraint(equalToConstant: 10),
fastSwitcherIndicator.heightAnchor.constraint(equalToConstant: 12),
])
} }
tabBar.isSpringLoaded = true tabBar.isSpringLoaded = true
@ -88,7 +79,20 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewWillAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
stateRestorationLogger.info("MainTabBarViewController: viewDidAppear, selectedIndex=\(self.selectedIndex, privacy: .public)")
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// i hate that we have to do this so often :S
// but doing it only in viewWillAppear makes it not appear initially
// doing it in viewWillAppear inside a DispatchQueue.main.async works initially but then it disappears when long-pressed
repositionFastSwitcherIndicator() repositionFastSwitcherIndicator()
} }
@ -201,11 +205,23 @@ extension MainTabBarViewController {
} }
extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate { extension MainTabBarViewController: FastAccountSwitcherViewControllerDelegate {
func fastAccountSwitcherAddToViewHierarchy(_ fastAccountSwitcher: FastAccountSwitcherViewController) {
view.addSubview(fastAccountSwitcher.view)
NSLayoutConstraint.activate([
fastAccountSwitcher.accountsStack.bottomAnchor.constraint(equalTo: fastAccountSwitcher.view.bottomAnchor),
fastAccountSwitcher.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
fastAccountSwitcher.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
fastAccountSwitcher.view.topAnchor.constraint(equalTo: view.topAnchor),
fastAccountSwitcher.view.bottomAnchor.constraint(equalTo: tabBar.topAnchor),
])
}
func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool { func fastAccountSwitcher(_ fastAccountSwitcher: FastAccountSwitcherViewController, triggerZoneContains point: CGPoint) -> Bool {
guard let myProfileButton = findMyProfileTabBarButton() else { guard let myProfileButton = findMyProfileTabBarButton() else {
return false return false
} }
let locationInButton = myProfileButton.convert(point, from: fastAccountSwitcher.view) let locationInButton = myProfileButton.convert(point, from: tabBar)
return myProfileButton.bounds.contains(locationInButton) return myProfileButton.bounds.contains(locationInButton)
} }
} }
@ -236,6 +252,7 @@ extension MainTabBarViewController: TuskerRootViewController {
self.selectedIndex = tab.rawValue self.selectedIndex = tab.rawValue
} }
} else { } else {
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
selectedIndex = tab.rawValue selectedIndex = tab.rawValue
} }
} }
@ -262,6 +279,10 @@ extension MainTabBarViewController: TuskerRootViewController {
exploreController.searchController.searchBar.text = query exploreController.searchController.searchBar.text = query
exploreController.resultsController.performSearch(query: query) exploreController.resultsController.performSearch(query: query)
} }
func presentPreferences(completion: (() -> Void)?) {
present(PreferencesNavigationController(mastodonController: mastodonController), animated: true, completion: completion)
}
} }
extension MainTabBarViewController: BackgroundableViewController { extension MainTabBarViewController: BackgroundableViewController {

View File

@ -13,4 +13,5 @@ protocol TuskerRootViewController: UIViewController {
func select(tab: MainTabBarViewController.Tab) func select(tab: MainTabBarViewController.Tab)
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
func performSearch(query: String) func performSearch(query: String)
func presentPreferences(completion: (() -> Void)?)
} }

View File

@ -140,11 +140,14 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
self.older = older self.older = older
} }
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let olderGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
var snapshot = currentSnapshot() let existingGroups = currentSnapshot().itemIdentifiers
snapshot.appendItems(groups, toSection: .notifications) let merged = NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
snapshot.appendSections([.notifications])
snapshot.appendItems(merged, toSection: .notifications)
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -173,15 +176,14 @@ class NotificationsTableViewController: DiffableTimelineLikeTableViewController<
self.newer = newer self.newer = newer
} }
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes) let newerGroups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) { self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
var snapshot = currentSnapshot() let existingGroups = currentSnapshot().itemIdentifiers
if let first = snapshot.itemIdentifiers(inSection: .notifications).first { let merged = NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
snapshot.insertItems(groups, beforeItem: first) var snapshot = NSDiffableDataSourceSnapshot<Section, NotificationGroup>()
} else { snapshot.appendSections([.notifications])
snapshot.appendItems(groups, toSection: .notifications) snapshot.appendItems(merged, toSection: .notifications)
}
completion(.success(snapshot)) completion(.success(snapshot))
} }
} }
@ -248,6 +250,9 @@ extension NotificationsTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
} }
extension NotificationsTableViewController: MenuActionProvider {
}
extension NotificationsTableViewController: StatusTableViewCellDelegate { extension NotificationsTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
cellHeightChanged() cellHeightChanged()

View File

@ -57,12 +57,6 @@ class ProfileViewController: UIPageViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
if let accountID = accountID {
mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount()
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -120,12 +114,23 @@ class ProfileViewController: UIPageViewController {
let req = Client.getAccount(id: accountID) let req = Client.getAccount(id: accountID)
mastodonController.run(req) { [weak self] (response) in mastodonController.run(req) { [weak self] (response) in
guard let self = self else { return } guard let self = self else { return }
guard case let .success(account, _) = response else { fatalError() } switch response {
self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in case .success(let account, _):
self.mastodonController.persistentContainer.addOrUpdate(account: account) { (account) in
DispatchQueue.main.async { DispatchQueue.main.async {
self.updateAccountUI() self.updateAccountUI()
} }
} }
case .failure(let error):
DispatchQueue.main.async {
let config = ToastConfiguration(from: error, with: "Loading", in: self) { [unowned self] (toast) in
toast.dismissToast(animated: true)
self.loadAccount()
}
self.showToast(configuration: config, animated: true)
}
}
} }
} }
} }
@ -271,3 +276,6 @@ extension ProfileViewController: TabbedPageViewController {
selectPage(at: currentIndex - 1, animated: true) selectPage(at: currentIndex - 1, animated: true)
} }
} }
extension ProfileViewController: ToastableViewController {
}

View File

@ -172,24 +172,9 @@ class SearchResultsViewController: EnhancedTableViewController {
} }
private func showSearchResults(_ results: SearchResults) { private func showSearchResults(_ results: SearchResults) {
let oldSnapshot = self.dataSource.snapshot()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in
if oldSnapshot.indexOfSection(.accounts) != nil {
oldSnapshot.itemIdentifiers(inSection: .accounts).forEach { (item) in
guard case let .account(id) = item else { return }
self.mastodonController.persistentContainer.account(for: id, in: context)?.decrementReferenceCount()
}
}
if oldSnapshot.indexOfSection(.statuses) != nil {
oldSnapshot.itemIdentifiers(inSection: .statuses).forEach { (item) in
guard case let .status(id, _) = item else { return }
self.mastodonController.persistentContainer.status(for: id, in: context)?.decrementReferenceCount()
}
}
let resultTypes = self.resultTypes let resultTypes = self.resultTypes
if !results.accounts.isEmpty && (resultTypes == nil || resultTypes!.contains(.accounts)) { if !results.accounts.isEmpty && (resultTypes == nil || resultTypes!.contains(.accounts)) {
snapshot.appendSections([.accounts]) snapshot.appendSections([.accounts])
@ -303,6 +288,15 @@ extension SearchResultsViewController: UISearchBarDelegate {
} }
} }
extension SearchResultsViewController: TuskerNavigationDelegate {
}
extension SearchResultsViewController: ToastableViewController {
}
extension SearchResultsViewController: MenuActionProvider {
}
extension SearchResultsViewController: StatusTableViewCellDelegate { extension SearchResultsViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {

View File

@ -60,16 +60,6 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
if let accountIDs = self.accountIDs, let container = mastodonController?.persistentContainer {
container.backgroundContext.perform {
for id in accountIDs {
container.account(for: id, in: container.backgroundContext)?.decrementReferenceCount()
}
}
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -83,11 +73,7 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude)) tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: CGFloat.leastNormalMagnitude))
if let accountIDs = accountIDs { if accountIDs == nil {
accountIDs.forEach { (id) in
self.mastodonController.persistentContainer.account(for: id)?.incrementReferenceCount()
}
} else {
// account IDs haven't been set, so perform a request to load them // account IDs haven't been set, so perform a request to load them
guard let status = mastodonController.persistentContainer.status(for: statusID) else { guard let status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError("Missing cached status \(statusID)") fatalError("Missing cached status \(statusID)")
@ -158,6 +144,15 @@ class StatusActionAccountListTableViewController: EnhancedTableViewController {
} }
extension StatusActionAccountListTableViewController: TuskerNavigationDelegate {
}
extension StatusActionAccountListTableViewController: ToastableViewController {
}
extension StatusActionAccountListTableViewController: MenuActionProvider {
}
extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate { extension StatusActionAccountListTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController } var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) { func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {

View File

@ -15,13 +15,17 @@ class HashtagTimelineViewController: TimelineTableViewController {
var toggleSaveButton: UIBarButtonItem! var toggleSaveButton: UIBarButtonItem!
var toggleSaveButtonTitle: String { var toggleSaveButtonTitle: String {
if SavedDataManager.shared.isSaved(hashtag: hashtag, for: mastodonController.accountInfo!) { if isHashtagSaved {
return NSLocalizedString("Unsave", comment: "unsave hashtag button") return NSLocalizedString("Unsave", comment: "unsave hashtag button")
} else { } else {
return NSLocalizedString("Save", comment: "save hashtag button") return NSLocalizedString("Save", comment: "save hashtag button")
} }
} }
private var isHashtagSaved: Bool {
mastodonController.persistentContainer.viewContext.objectExists(for: SavedHashtag.fetchRequest(name: hashtag.name))
}
init(for hashtag: Hashtag, mastodonController: MastodonController) { init(for hashtag: Hashtag, mastodonController: MastodonController) {
self.hashtag = hashtag self.hashtag = hashtag
@ -48,11 +52,13 @@ class HashtagTimelineViewController: TimelineTableViewController {
// MARK: - Interaction // MARK: - Interaction
@objc func toggleSaveButtonPressed() { @objc func toggleSaveButtonPressed() {
if SavedDataManager.shared.isSaved(hashtag: hashtag, for: mastodonController.accountInfo!) { let context = mastodonController.persistentContainer.viewContext
SavedDataManager.shared.remove(hashtag: hashtag, for: mastodonController.accountInfo!) if let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first {
context.delete(existing)
} else { } else {
SavedDataManager.shared.add(hashtag: hashtag, for: mastodonController.accountInfo!) _ = SavedHashtag(hashtag: hashtag, context: context)
} }
try! context.save()
} }
} }

View File

@ -22,9 +22,13 @@ class InstanceTimelineViewController: TimelineTableViewController {
let instanceURL: URL let instanceURL: URL
let instanceMastodonController: MastodonController let instanceMastodonController: MastodonController
var toggleSaveButton: UIBarButtonItem! private var toggleSaveButton: UIBarButtonItem!
var toggleSaveButtonTitle: String {
if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { private var isInstanceSaved: Bool {
parentMastodonController!.persistentContainer.viewContext.objectExists(for: SavedInstance.fetchRequest(url: instanceURL))
}
private var toggleSaveButtonTitle: String {
if isInstanceSaved {
return NSLocalizedString("Unsave", comment: "unsave instance button") return NSLocalizedString("Unsave", comment: "unsave instance button")
} else { } else {
return NSLocalizedString("Save", comment: "save instance button") return NSLocalizedString("Save", comment: "save instance button")
@ -81,13 +85,16 @@ class InstanceTimelineViewController: TimelineTableViewController {
// MARK: - Interaction // MARK: - Interaction
@objc func toggleSaveButtonPressed() { @objc func toggleSaveButtonPressed() {
if SavedDataManager.shared.isSaved(instance: instanceURL, for: parentMastodonController!.accountInfo!) { let context = parentMastodonController!.persistentContainer.viewContext
SavedDataManager.shared.remove(instance: instanceURL, for: parentMastodonController!.accountInfo!) let existing = try? context.fetch(SavedInstance.fetchRequest(url: instanceURL)).first
if let existing = existing {
context.delete(existing)
delegate?.didUnsaveInstance(url: instanceURL) delegate?.didUnsaveInstance(url: instanceURL)
} else { } else {
SavedDataManager.shared.add(instance: instanceURL, for: parentMastodonController!.accountInfo!) _ = SavedInstance(url: instanceURL, context: context)
delegate?.didSaveInstance(url: instanceURL) delegate?.didSaveInstance(url: instanceURL)
} }
try? context.save()
} }
} }

View File

@ -42,18 +42,6 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
guard let persistentContainer = mastodonController?.persistentContainer,
let dataSource = dataSource else { return }
// decrement reference counts of any statuses we still have
// if the app is currently being quit, this will not affect the persisted data because
// the persistent container would already have been saved in SceneDelegate.sceneDidEnterBackground(_:)
// todo: remove the whole reference count system
for case let .status(id: id, state: _) in dataSource.snapshot().itemIdentifiers {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -245,12 +233,6 @@ class TimelineTableViewController: DiffableTimelineLikeTableViewController<Timel
} }
} }
override func willRemoveItems(_ items: [Item]) {
for case let .status(id: id, state: _) in items {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
super.scrollViewWillBeginDragging(scrollView) super.scrollViewWillBeginDragging(scrollView)
@ -312,6 +294,9 @@ extension TimelineTableViewController: StatusTableViewCellDelegate {
} }
} }
extension TimelineTableViewController: MenuActionProvider {
}
extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching { extension TimelineTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
let ids: [String] = indexPaths.compactMap { let ids: [String] = indexPaths.compactMap {

View File

@ -10,29 +10,33 @@ import UIKit
import SafariServices import SafariServices
import Pachyderm import Pachyderm
protocol MenuPreviewProvider: AnyObject { protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get }
var toastableViewController: ToastableViewController? { get }
}
protocol MenuPreviewProvider: AnyObject {
typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement]) typealias PreviewProviders = (content: UIContextMenuContentPreviewProvider, actions: () -> [UIMenuElement])
var navigationDelegate: TuskerNavigationDelegate? { get }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders?
} }
protocol CustomPreviewPresenting { protocol CustomPreviewPresenting {
func presentFromPreview(presenter: UIViewController) func presentFromPreview(presenter: UIViewController)
} }
extension MenuPreviewProvider { extension MenuActionProvider where Self: TuskerNavigationDelegate {
var navigationDelegate: TuskerNavigationDelegate? { self }
}
extension MenuActionProvider where Self: ToastableViewController {
var toastableViewController: ToastableViewController? { self }
}
extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController } private var mastodonController: MastodonController? { navigationDelegate?.apiController }
// Default no-op implementation
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return nil
}
func actionsForProfile(accountID: String, sourceView: UIView?) -> [UIMenuElement] { func actionsForProfile(accountID: String, sourceView: UIView?) -> [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 [] }
@ -55,7 +59,7 @@ extension MenuPreviewProvider {
draft.visibility = .direct draft.visibility = .direct
self.navigationDelegate?.compose(editing: draft) self.navigationDelegate?.compose(editing: draft)
}), }),
UIDeferredMenuElement({ (elementHandler) in UIDeferredMenuElement.uncachedIfPossible({ (elementHandler) in
Task { @MainActor in Task { @MainActor in
if let action = await self.followAction(for: accountID, mastodonController: mastodonController) { if let action = await self.followAction(for: accountID, mastodonController: mastodonController) {
elementHandler([action]) elementHandler([action])
@ -93,18 +97,24 @@ extension MenuPreviewProvider {
} }
func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] { func actionsForHashtag(_ hashtag: Hashtag, sourceView: UIView?) -> [UIMenuElement] {
let account = mastodonController!.accountInfo! let actionsSection: [UIMenuElement]
let saved = SavedDataManager.shared.isSaved(hashtag: hashtag, for: account) if let mastodonController = mastodonController,
mastodonController.loggedIn {
let actionsSection = [ let context = mastodonController.persistentContainer.viewContext
createAction(identifier: "save", title: saved ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in let existing = try? context.fetch(SavedHashtag.fetchRequest(name: hashtag.name)).first
if saved { actionsSection = [
SavedDataManager.shared.remove(hashtag: hashtag, for: account) createAction(identifier: "save", title: existing != nil ? "Unsave Hashtag" : "Save Hashtag", systemImageName: "number", handler: { (_) in
if let existing = existing {
context.delete(existing)
} else { } else {
SavedDataManager.shared.add(hashtag: hashtag, for: account) _ = SavedHashtag(hashtag: hashtag, context: context)
} }
try! context.save()
}) })
] ]
} else {
actionsSection = []
}
let shareSection = actionsForURL(hashtag.url, sourceView: sourceView) let shareSection = actionsForURL(hashtag.url, sourceView: sourceView)
@ -135,8 +145,17 @@ extension MenuPreviewProvider {
guard let self = self else { return } guard let self = self else { return }
let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id) let request = (bookmarked ? Status.unbookmark : Status.bookmark)(status.id)
self.mastodonController?.run(request) { (response) in self.mastodonController?.run(request) { (response) in
if case let .success(status, _) = response { switch response {
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(bookmarked ? "Unb" : "B")ookmarking", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
} }
} }
}), }),
@ -157,8 +176,16 @@ extension MenuPreviewProvider {
guard let self = self else { return } guard let self = self else { return }
let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id) let request = (muted ? Status.unmuteConversation : Status.muteConversation)(status.id)
self.mastodonController?.run(request) { (response) in self.mastodonController?.run(request) { (response) in
if case let .success(status, _) = response { switch response {
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(muted ? "Unm" : "M")uting", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
} }
} }
})) }))
@ -173,8 +200,16 @@ extension MenuPreviewProvider {
let request = (pinned ? Status.unpin : Status.pin)(status.id) let request = (pinned ? Status.unpin : Status.pin)(status.id)
self.mastodonController?.run(request, completion: { [weak self] (response) in self.mastodonController?.run(request, completion: { [weak self] (response) in
guard let self = self else { return } guard let self = self else { return }
if case let .success(status, _) = response { switch response {
self.mastodonController?.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) case .success(let status, _):
self.mastodonController?.persistentContainer.addOrUpdate(status: status)
case .failure(let error):
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(pinned ? "Unp" :"P")inning", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
} }
}) })
})) }))
@ -186,10 +221,20 @@ extension MenuPreviewProvider {
guard let mastodonController = self?.mastodonController else { return } guard let mastodonController = self?.mastodonController else { return }
let request = Client.getStatus(id: status.id) let request = Client.getStatus(id: status.id)
mastodonController.run(request, completion: { (response) in mastodonController.run(request, completion: { (response) in
if case let .success(status, _) = response { switch response {
case .success(let status, _):
// todo: this shouldn't really use the viewContext, but for some reason saving the // todo: this shouldn't really use the viewContext, but for some reason saving the
// backgroundContext with the new version of the status isn't updating the viewContext // backgroundContext with the new version of the status isn't updating the viewContext
mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false, context: mastodonController.persistentContainer.viewContext) DispatchQueue.main.async {
mastodonController.persistentContainer.addOrUpdate(status: status, context: mastodonController.persistentContainer.viewContext)
}
case .failure(let error):
if let toastable = self?.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error Refreshing Poll", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
} }
}) })
}), at: 0) }), at: 0)
@ -257,7 +302,7 @@ extension MenuPreviewProvider {
actions.append(UIWindowScene.ActivationAction { (_) in actions.append(UIWindowScene.ActivationAction { (_) in
return .init(userActivity: activity(), options: options, preview: nil) return .init(userActivity: activity(), options: options, preview: nil)
}) })
} else if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { } else if UIApplication.shared.supportsMultipleScenes {
actions.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "rectangle.badge.plus", handler: { (_) in actions.append(createAction(identifier: "new_window", title: "Open in New Window", systemImageName: "rectangle.badge.plus", handler: { (_) in
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity(), options: nil, errorHandler: nil) UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity(), options: nil, errorHandler: nil)
})) }))
@ -279,8 +324,13 @@ extension MenuPreviewProvider {
let request = (following ? Account.unfollow : Account.follow)(accountID) let request = (following ? Account.unfollow : Account.follow)(accountID)
mastodonController.run(request) { response in mastodonController.run(request) { response in
switch response { switch response {
case .failure(_): case .failure(let error):
fatalError() if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error \(following ? "Unf" : "F")ollowing", in: toastable, retryAction: nil)
DispatchQueue.main.async {
toastable.showToast(configuration: config, animated: true)
}
}
case .success(let relationship, _): case .success(let relationship, _):
mastodonController.persistentContainer.addOrUpdate(relationship: relationship) mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
} }
@ -307,3 +357,13 @@ extension SFSafariViewController: CustomPreviewPresenting {
presenter.present(self, animated: true) presenter.present(self, animated: true)
} }
} }
private extension UIDeferredMenuElement {
static func uncachedIfPossible(_ elementProvider: @escaping (@escaping ([UIMenuElement]) -> Void) -> Void) -> UIDeferredMenuElement {
if #available(iOS 15.0, *) {
return UIDeferredMenuElement.uncached(elementProvider)
} else {
return UIDeferredMenuElement(elementProvider)
}
}
}

View File

@ -0,0 +1,122 @@
//
// PostService.swift
// Tusker
//
// Created by Shadowfacts on 4/27/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class PostService: ObservableObject {
private let mastodonController: MastodonController
private let draft: Draft
let totalSteps: Int
@Published var currentStep = 1
init(mastodonController: MastodonController, draft: Draft) {
self.mastodonController = mastodonController
self.draft = draft
// 2 steps (request data, then upload) for each attachment
self.totalSteps = 2 + (draft.attachments.count * 2)
}
@MainActor
func post() async throws {
guard draft.hasContent else {
return
}
// save before posting, so if a crash occurs during network request, the status won't be lost
DraftsManager.save()
let uploadedAttachments = try await uploadAttachments()
let contentWarning = draft.contentWarningEnabled ? draft.contentWarning : nil
let sensitive = contentWarning != nil
let request = Client.createStatus(
text: draft.textForPosting(on: mastodonController.instanceFeatures),
contentType: Preferences.shared.statusContentType,
inReplyTo: draft.inReplyToID,
media: uploadedAttachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: draft.visibility,
language: nil,
pollOptions: draft.poll?.options.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
)
do {
let (_, _) = try await mastodonController.run(request)
currentStep += 1
DraftsManager.shared.remove(self.draft)
} catch let error as Client.Error {
throw Error.posting(error)
}
}
private func uploadAttachments() async throws -> [Attachment] {
var attachments: [Attachment] = []
attachments.reserveCapacity(draft.attachments.count)
for (index, attachment) in draft.attachments.enumerated() {
let data: Data
let mimeType: String
do {
(data, mimeType) = try await getData(for: attachment)
currentStep += 1
} catch let error as CompositionAttachmentData.Error {
throw Error.attachmentData(index: index, cause: error)
}
do {
let uploaded = try await uploadAttachment(data: data, mimeType: mimeType, description: attachment.description)
attachments.append(uploaded)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
}
return attachments
}
private func getData(for attachment: CompositionAttachment) async throws -> (Data, String) {
return try await withCheckedThrowingContinuation { continuation in
attachment.data.getData { result in
switch result {
case let .success(res):
continuation.resume(returning: res)
case let .failure(error):
continuation.resume(throwing: error)
}
}
}
}
private func uploadAttachment(data: Data, mimeType: String, description: String?) async throws -> Attachment {
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
let req = Client.upload(attachment: formAttachment, description: description)
return try await mastodonController.run(req).0
}
enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: CompositionAttachmentData.Error)
case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error)
var localizedDescription: String {
switch self {
case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentUpload(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .posting(error):
return error.localizedDescription
}
}
}
}

View File

@ -10,13 +10,25 @@ import Foundation
extension NSUserActivity { extension NSUserActivity {
var displaysAuxiliaryScene: Bool {
get {
(userInfo?["displaysAuxiliaryScene"] as? Bool) ?? false
}
set {
if userInfo == nil {
userInfo = [:]
}
userInfo!["displaysAuxiliaryScene"] = newValue
}
}
convenience init(type: UserActivityType) { convenience init(type: UserActivityType) {
self.init(activityType: type.rawValue) self.init(activityType: type.rawValue)
} }
func handleResume() -> Bool { func handleResume(manager: UserActivityManager) -> Bool {
guard let type = UserActivityType(rawValue: activityType) else { return false } guard let type = UserActivityType(rawValue: activityType) else { return false }
type.handle(self) type.handle(manager)(self)
return true return true
} }

View File

@ -9,25 +9,32 @@
import UIKit import UIKit
import Intents import Intents
import Pachyderm import Pachyderm
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
class UserActivityManager { class UserActivityManager {
private let scene: UIWindowScene
init(scene: UIWindowScene) {
self.scene = scene
}
// MARK: - Utils // MARK: - Utils
private static let encoder = PropertyListEncoder() private static let encoder = PropertyListEncoder()
private static let decoder = PropertyListDecoder() private static let decoder = PropertyListDecoder()
private static var mastodonController: MastodonController { private var mastodonController: MastodonController {
let scene = UIApplication.shared.activeOrBackgroundScene! scene.session.mastodonController!
return scene.session.mastodonController!
} }
private static func getMainViewController() -> TuskerRootViewController { private func getMainViewController() -> TuskerRootViewController {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! let window = scene.windows.first { $0.isKeyWindow } ?? scene.windows.first!
let window = scene.windows.first { $0.isKeyWindow }!
return window.rootViewController as! TuskerRootViewController return window.rootViewController as! TuskerRootViewController
} }
private static func present(_ vc: UIViewController, animated: Bool = true) { private func present(_ vc: UIViewController, animated: Bool = true) {
getMainViewController().present(vc, animated: animated) getMainViewController().present(vc, animated: animated)
} }
@ -66,7 +73,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleNewPost(activity: NSUserActivity) { func handleNewPost(activity: NSUserActivity) {
// TODO: check not currently showing compose screen // TODO: check not currently showing compose screen
let mentioning = activity.userInfo?["mentioning"] as? String let mentioning = activity.userInfo?["mentioning"] as? String
let draft = mastodonController.createDraft(mentioningAcct: mentioning) let draft = mastodonController.createDraft(mentioningAcct: mentioning)
@ -111,14 +118,14 @@ class UserActivityManager {
return activity return activity
} }
static func handleCheckNotifications(activity: NSUserActivity) { func handleCheckNotifications(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .notifications) mainViewController.select(tab: .notifications)
if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController, if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController,
let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController { let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController {
navigationController.popToRootViewController(animated: false) navigationController.popToRootViewController(animated: false)
notificationsPageController.loadViewIfNeeded() notificationsPageController.loadViewIfNeeded()
notificationsPageController.selectMode(getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode) notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
} }
} }
@ -168,11 +175,11 @@ class UserActivityManager {
let data = activity.userInfo?["timelineData"] as? Data else { let data = activity.userInfo?["timelineData"] as? Data else {
return nil return nil
} }
return try? decoder.decode(Timeline.self, from: data) return try? UserActivityManager.decoder.decode(Timeline.self, from: data)
} }
static func handleShowTimeline(activity: NSUserActivity) { func handleShowTimeline(activity: NSUserActivity) {
guard let timeline = getTimeline(from: activity) else { return } guard let timeline = Self.getTimeline(from: activity) else { return }
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .timelines) mainViewController.select(tab: .timelines)
@ -228,7 +235,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleSearch(activity: NSUserActivity) { func handleSearch(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .explore) mainViewController.select(tab: .explore)
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController, if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController,
@ -247,7 +254,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleBookmarks(activity: NSUserActivity) { func handleBookmarks(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .explore) mainViewController.select(tab: .explore)
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController { if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController {
@ -265,7 +272,7 @@ class UserActivityManager {
return activity return activity
} }
static func handleMyProfile(activity: NSUserActivity) { func handleMyProfile(activity: NSUserActivity) {
let mainViewController = getMainViewController() let mainViewController = getMainViewController()
mainViewController.select(tab: .myProfile) mainViewController.select(tab: .myProfile)
} }

View File

@ -21,7 +21,7 @@ enum UserActivityType: String {
} }
extension UserActivityType { extension UserActivityType {
var handle: (NSUserActivity) -> Void { var handle: (UserActivityManager) -> (NSUserActivity) -> Void {
switch self { switch self {
case .mainScene: case .mainScene:
fatalError("cannot handle main scene activity") fatalError("cannot handle main scene activity")

View File

@ -11,7 +11,7 @@ import SwiftSoup
class AccountTableViewCell: UITableViewCell { class AccountTableViewCell: UITableViewCell {
weak var delegate: TuskerNavigationDelegate? weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: UIImageView!
@ -98,13 +98,11 @@ extension AccountTableViewCell: SelectableTableViewCell {
} }
extension AccountTableViewCell: MenuPreviewProvider { extension AccountTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
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.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) } actions: { self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? [] }
) )
} }
} }
@ -117,6 +115,7 @@ extension AccountTableViewCell: DraggableTableViewCell {
} }
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -78,6 +78,11 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines)
mutAttrString.collapseWhitespace() mutAttrString.collapseWhitespace()
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
mutAttrString.addAttribute(.paragraphStyle, value: style, range: mutAttrString.fullRange)
self.attributedText = mutAttrString self.attributedText = mutAttrString
} }
@ -99,7 +104,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
switch node.tagName() { switch node.tagName() {
case "br": case "br":
attributed.append(NSAttributedString(string: "\n")) // 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": case "a":
if let link = try? node.attr("href"), if let link = try? node.attr("href"),
let webURL = WebURL(link), let webURL = WebURL(link),
@ -107,7 +114,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
attributed.addAttribute(.link, value: url, range: attributed.fullRange) attributed.addAttribute(.link, value: url, range: attributed.fullRange)
} }
case "p": case "p":
attributed.append(NSAttributedString(string: "\n\n")) attributed.append(NSAttributedString(string: "\n\n", attributes: [.font: defaultFont]))
case "em", "i": case "em", "i":
let currentFont: UIFont let currentFont: UIFont
if attributed.length == 0 { if attributed.length == 0 {
@ -129,11 +136,11 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
case "code": case "code":
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange) attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
case "pre": case "pre":
attributed.append(NSAttributedString(string: "\n\n"))
attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange) attributed.addAttribute(.font, value: UIFont.monospacedSystemFont(ofSize: defaultFont.pointSize, weight: .regular), range: attributed.fullRange)
attributed.append(NSAttributedString(string: "\n\n"))
case "ol", "ul": case "ol", "ul":
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
attributed.append(NSAttributedString(string: "\n\n")) attributed.append(NSAttributedString(string: "\n\n"))
attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines)
case "li": case "li":
let parentEl = node.parent()! let parentEl = node.parent()!
let parentTag = parentEl.tagName() let parentTag = parentEl.tagName()
@ -143,12 +150,12 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
// we use the monospace digit font so that the periods of all the list items line up // we use the monospace digit font so that the periods of all the list items line up
bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: defaultFont.pointSize, weight: .regular)]) bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: defaultFont.pointSize, weight: .regular)])
} else if parentTag == "ul" { } else if parentTag == "ul" {
bullet = NSAttributedString(string: "\u{2022}\t") bullet = NSAttributedString(string: "\u{2022}\t", attributes: [.font: defaultFont])
} else { } else {
bullet = NSAttributedString() bullet = NSAttributedString()
} }
attributed.insert(bullet, at: 0) attributed.insert(bullet, at: 0)
attributed.append(NSAttributedString(string: "\n")) attributed.append(NSAttributedString(string: "\n", attributes: [.font: defaultFont]))
default: default:
break break
} }
@ -243,9 +250,10 @@ extension ContentTextView: UITextViewDelegate {
} }
} }
extension ContentTextView: MenuPreviewProvider { extension ContentTextView: MenuActionProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { var toastableViewController: ToastableViewController? {
fatalError("unimplemented") // todo: pass this down through the text view
nil
} }
} }

View File

@ -10,6 +10,10 @@ import UIKit
class FastAccountSwitcherIndicatorView: UIView { class FastAccountSwitcherIndicatorView: UIView {
override var intrinsicContentSize: CGSize {
CGSize(width: 10, height: 12)
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)

View File

@ -12,7 +12,7 @@ import SwiftSoup
class ActionNotificationGroupTableViewCell: UITableViewCell { class ActionNotificationGroupTableViewCell: UITableViewCell {
weak var delegate: TuskerNavigationDelegate? weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var actionImageView: UIImageView! @IBOutlet weak var actionImageView: UIImageView!
@ -244,8 +244,6 @@ extension ActionNotificationGroupTableViewCell: SelectableTableViewCell {
} }
extension ActionNotificationGroupTableViewCell: MenuPreviewProvider { extension ActionNotificationGroupTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return (content: { return (content: {
let notifications = self.group.notifications let notifications = self.group.notifications

View File

@ -11,7 +11,7 @@ import Pachyderm
class FollowNotificationGroupTableViewCell: UITableViewCell { class FollowNotificationGroupTableViewCell: UITableViewCell {
weak var delegate: TuskerNavigationDelegate? weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var avatarStackView: UIStackView! @IBOutlet weak var avatarStackView: UIStackView!
@ -196,8 +196,6 @@ extension FollowNotificationGroupTableViewCell: SelectableTableViewCell {
} }
extension FollowNotificationGroupTableViewCell: MenuPreviewProvider { extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil } guard let mastodonController = mastodonController else { return nil }
let accountIDs = self.group.notifications.map { $0.account.id } let accountIDs = self.group.notifications.map { $0.account.id }
@ -209,7 +207,7 @@ extension FollowNotificationGroupTableViewCell: MenuPreviewProvider {
} }
}, actions: { }, actions: {
if accountIDs.count == 1 { if accountIDs.count == 1 {
return self.actionsForProfile(accountID: accountIDs.first!, sourceView: self) return self.delegate?.actionsForProfile(accountID: accountIDs.first!, sourceView: self) ?? []
} else { } else {
return [] return []
} }
@ -225,6 +223,7 @@ extension FollowNotificationGroupTableViewCell: DraggableTableViewCell {
let notification = group.notifications[0] let notification = group.notifications[0]
let provider = NSItemProvider(object: notification.account.url as NSURL) let provider = NSItemProvider(object: notification.account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: notification.account.id, accountID: mastodonController.accountInfo!.id) let activity = UserActivityManager.showProfileActivity(id: notification.account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -11,7 +11,7 @@ import Pachyderm
class FollowRequestNotificationTableViewCell: UITableViewCell { class FollowRequestNotificationTableViewCell: UITableViewCell {
weak var delegate: TuskerNavigationDelegate? weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var stackView: UIStackView! @IBOutlet weak var stackView: UIStackView!
@ -134,13 +134,24 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
acceptButton.isEnabled = false acceptButton.isEnabled = false
rejectButton.isEnabled = false rejectButton.isEnabled = false
Task {
let request = Account.rejectFollowRequest(account) let request = Account.rejectFollowRequest(account)
mastodonController.run(request) { (response) in do {
guard case .success(_, _) = response else { fatalError() } _ = try await mastodonController.run(request)
DispatchQueue.main.async {
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
self.actionButtonsStackView.isHidden = true self.actionButtonsStackView.isHidden = true
self.addLabel(NSLocalizedString("Rejected", comment: "rejected follow request label")) self.addLabel(NSLocalizedString("Rejected", comment: "rejected follow request label"))
} catch let error as Client.Error {
acceptButton.isEnabled = true
rejectButton.isEnabled = true
if let toastable = delegate?.toastableViewController {
let config = ToastConfiguration(from: error, with: "Rejecting Follow", in: toastable) { [weak self] toast in
toast.dismissToast(animated: true)
self?.rejectButtonPressed()
}
toastable.showToast(configuration: config, animated: true)
}
} }
} }
} }
@ -149,13 +160,25 @@ class FollowRequestNotificationTableViewCell: UITableViewCell {
acceptButton.isEnabled = false acceptButton.isEnabled = false
rejectButton.isEnabled = false rejectButton.isEnabled = false
Task {
let request = Account.authorizeFollowRequest(account) let request = Account.authorizeFollowRequest(account)
mastodonController.run(request) { (response) in do {
guard case .success(_, _) = response else { fatalError() } _ = try await mastodonController.run(request)
DispatchQueue.main.async {
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
self.actionButtonsStackView.isHidden = true self.actionButtonsStackView.isHidden = true
self.addLabel(NSLocalizedString("Accepted", comment: "accepted follow request label")) self.addLabel(NSLocalizedString("Accepted", comment: "accepted follow request label"))
} catch let error as Client.Error {
acceptButton.isEnabled = true
rejectButton.isEnabled = true
if let toastable = delegate?.toastableViewController {
let config = ToastConfiguration(from: error, with: "Accepting Follow", in: toastable) { [weak self] toast in
toast.dismissToast(animated: true)
self?.acceptButtonPressed()
}
toastable.showToast(configuration: config, animated: true)
}
} }
} }
} }
@ -169,8 +192,6 @@ extension FollowRequestNotificationTableViewCell: SelectableTableViewCell {
} }
extension FollowRequestNotificationTableViewCell: MenuPreviewProvider { extension FollowRequestNotificationTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController else { return nil } guard let mastodonController = mastodonController else { return nil }
return (content: { return (content: {
@ -185,6 +206,7 @@ extension FollowRequestNotificationTableViewCell: DraggableTableViewCell {
func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] { func dragItemsForBeginning(session: UIDragSession) -> [UIDragItem] {
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -12,7 +12,7 @@ import SwiftSoup
class PollFinishedTableViewCell: UITableViewCell { class PollFinishedTableViewCell: UITableViewCell {
weak var delegate: TuskerNavigationDelegate? weak var delegate: (TuskerNavigationDelegate & MenuActionProvider)?
var mastodonController: MastodonController? { delegate?.apiController } var mastodonController: MastodonController? { delegate?.apiController }
@IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var displayNameLabel: EmojiLabel!
@ -91,8 +91,6 @@ extension PollFinishedTableViewCell: SelectableTableViewCell {
} }
extension PollFinishedTableViewCell: MenuPreviewProvider { extension PollFinishedTableViewCell: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? { func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let delegate = delegate, guard let delegate = delegate,
let statusID = notification?.status?.id, let statusID = notification?.status?.id,
@ -102,7 +100,7 @@ extension PollFinishedTableViewCell: MenuPreviewProvider {
return (content: { return (content: {
delegate.conversation(mainStatusID: statusID, state: .unknown) delegate.conversation(mainStatusID: statusID, state: .unknown)
}, actions: { }, actions: {
self.actionsForStatus(status, sourceView: self) delegate.actionsForStatus(status, sourceView: self)
}) })
} }
} }

View File

@ -0,0 +1,79 @@
//
// PollVoteButton.swift
// Tusker
//
// Created by Shadowfacts on 5/1/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
/// Wraps a UILabel and UIButton to allow setting disabled titles on Catalyst, where `setTitle(_:for:)` only works for the normal state.
class PollVoteButton: UIView {
var disabledTitle: String = "" {
didSet {
update()
}
}
var isEnabled = true {
didSet {
update()
}
}
private var button = UIButton(type: .system)
#if targetEnvironment(macCatalyst)
private var label = UILabel()
#endif
override init(frame: CGRect) {
super.init(frame: frame)
button.translatesAutoresizingMaskIntoConstraints = false
button.setTitleColor(.secondaryLabel, for: .disabled)
embedSubview(button)
#if targetEnvironment(macCatalyst)
label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false
embedSubview(label)
#endif
update()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func addTarget(_ target: Any, action: Selector) {
button.addTarget(target, action: action, for: .touchUpInside)
}
func setFont(_ font: UIFont) {
button.titleLabel!.font = font
#if targetEnvironment(macCatalyst)
label.font = font
#endif
}
private func update() {
button.isEnabled = isEnabled
if isEnabled {
#if targetEnvironment(macCatalyst)
label.isHidden = true
button.isHidden = false
#endif
button.setTitle("Vote", for: .normal)
} else {
#if targetEnvironment(macCatalyst)
label.text = disabledTitle
label.isHidden = false
button.isHidden = true
#else
button.setTitle(disabledTitle, for: .disabled)
#endif
}
}
}

View File

@ -21,12 +21,13 @@ class StatusPollView: UIView {
}() }()
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
weak var toastableViewController: ToastableViewController?
private var statusID: String! private var statusID: String!
private(set) var poll: Poll? private(set) var poll: Poll?
private var optionsView: PollOptionsView! private var optionsView: PollOptionsView!
private var voteButton: UIButton! private var voteButton: PollVoteButton!
private var infoLabel: UILabel! private var infoLabel: UILabel!
private var canVote = true private var canVote = true
@ -54,12 +55,16 @@ class StatusPollView: UIView {
infoLabel.adjustsFontSizeToFitWidth = true infoLabel.adjustsFontSizeToFitWidth = true
addSubview(infoLabel) addSubview(infoLabel)
voteButton = UIButton(type: .system) // voteButton = UIButton(type: .system)
// voteButton.translatesAutoresizingMaskIntoConstraints = false
// voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
// voteButton.setTitle("Vote", for: .normal)
// voteButton.setTitleColor(.secondaryLabel, for: .disabled)
// voteButton.titleLabel!.font = infoLabel.font
voteButton = PollVoteButton()
voteButton.translatesAutoresizingMaskIntoConstraints = false voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside) voteButton.addTarget(self, action: #selector(votePressed))
voteButton.setTitle("Vote", for: .normal) voteButton.setFont(infoLabel.font)
voteButton.setTitleColor(.secondaryLabel, for: .disabled)
voteButton.titleLabel!.font = infoLabel.font
addSubview(voteButton) addSubview(voteButton)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -117,17 +122,17 @@ class StatusPollView: UIView {
} }
if expired { if expired {
voteButton.setTitle("Expired", for: .disabled) voteButton.disabledTitle = "Expired"
} else if poll.voted ?? false { } else if poll.voted ?? false {
if status.account.id == mastodonController.account.id { if status.account.id == mastodonController.account.id {
voteButton.setTitle("", for: .disabled) voteButton.isHidden = true
} else { } else {
voteButton.setTitle("Voted", for: .disabled) voteButton.disabledTitle = "Voted"
} }
} else if poll.multiple { } else if poll.multiple {
voteButton.setTitle("Select multiple", for: .disabled) voteButton.disabledTitle = "Select multiple"
} else { } else {
voteButton.setTitle("Select one", for: .disabled) voteButton.disabledTitle = "Select one"
} }
voteButton.isEnabled = false voteButton.isEnabled = false
} }
@ -139,7 +144,7 @@ class StatusPollView: UIView {
@objc private func votePressed() { @objc private func votePressed() {
optionsView.isEnabled = false optionsView.isEnabled = false
voteButton.isEnabled = false voteButton.isEnabled = false
voteButton.setTitle("Voted", for: .disabled) voteButton.disabledTitle = "Voted"
UIImpactFeedbackGenerator(style: .medium).impactOccurred() UIImpactFeedbackGenerator(style: .medium).impactOccurred()
@ -147,7 +152,14 @@ class StatusPollView: UIView {
mastodonController.run(request) { (response) in mastodonController.run(request) { (response) in
switch response { switch response {
case let .failure(error): case let .failure(error):
fatalError("error voting in poll: \(error)") DispatchQueue.main.async {
self.updateUI(status: self.mastodonController.persistentContainer.status(for: self.statusID)!, poll: self.poll)
if let toastable = self.toastableViewController {
let config = ToastConfiguration(from: error, with: "Error Voting", in: toastable, retryAction: nil)
toastable.showToast(configuration: config, animated: true)
}
}
case let .success(poll, _): case let .success(poll, _):
let container = self.mastodonController.persistentContainer let container = self.mastodonController.persistentContainer

View File

@ -10,7 +10,7 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate { protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
} }
@ -106,7 +106,7 @@ class ProfileHeaderView: UIView {
updateImages(account: account) updateImages(account: account)
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton)) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, sourceView: moreButton) ?? [])
noteTextView.navigationDelegate = delegate noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
@ -182,12 +182,12 @@ class ProfileHeaderView: UIView {
@objc private func updateUIForPreferences() { @objc private func updateUIForPreferences() {
// todo: mastodonController should never be nil, but ProfileHeaderViews are getting leaked // todo: mastodonController should never be nil, but ProfileHeaderViews are getting leaked
guard let mastodonController = mastodonController else { guard let mastodonController = mastodonController,
// nil if prefs changed before own account is loaded
let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) else {
return return
} }
guard let account = mastodonController.persistentContainer.account(for: accountID) else {
fatalError("Missing cached account \(accountID!)")
}
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
@ -272,7 +272,3 @@ extension ProfileHeaderView: UIPointerInteractionDelegate {
return UIPointerStyle(effect: .lift(preview), shape: .none) return UIPointerStyle(effect: .lift(preview), shape: .none)
} }
} }
extension ProfileHeaderView: MenuPreviewProvider {
var navigationDelegate: TuskerNavigationDelegate? { delegate }
}

View File

@ -11,11 +11,11 @@ import Pachyderm
import Combine import Combine
import AVKit import AVKit
protocol StatusTableViewCellDelegate: TuskerNavigationDelegate { protocol StatusTableViewCellDelegate: TuskerNavigationDelegate, MenuActionProvider {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell)
} }
class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider { class BaseStatusTableViewCell: UITableViewCell {
weak var delegate: StatusTableViewCellDelegate? { weak var delegate: StatusTableViewCellDelegate? {
didSet { didSet {
@ -153,7 +153,8 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
cardView.card = status.card cardView.card = status.card
cardView.isHidden = status.card == nil cardView.isHidden = status.card == nil
cardView.navigationDelegate = navigationDelegate cardView.navigationDelegate = delegate
cardView.actionProvider = delegate
attachmentsView.updateUI(status: status) attachmentsView.updateUI(status: status)
@ -206,10 +207,11 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
// 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: actionsForStatus(status, sourceView: moreButton, includeReply: false)) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForStatus(status, sourceView: moreButton, includeReply: false) ?? [])
pollView.isHidden = status.poll == nil pollView.isHidden = status.poll == nil
pollView.mastodonController = mastodonController pollView.mastodonController = mastodonController
pollView.toastableViewController = delegate?.toastableViewController
pollView.updateUI(status: status, poll: status.poll) pollView.updateUI(status: status, poll: status.poll)
} }
@ -326,13 +328,6 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
showStatusAutomatically = false showStatusAutomatically = false
} }
// MARK: - MenuPreviewProvider
var navigationDelegate: TuskerNavigationDelegate? { return delegate }
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
return nil
}
// MARK: - Interaction // MARK: - Interaction
@IBAction func collapseButtonPressed() { @IBAction func collapseButtonPressed() {
@ -393,7 +388,7 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {
self.favorited = newStatus.favourited ?? false self.favorited = newStatus.favourited ?? false
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else { } else {
self.favorited = oldValue self.favorited = oldValue
@ -435,7 +430,7 @@ class BaseStatusTableViewCell: UITableViewCell, MenuPreviewProvider {
DispatchQueue.main.async { DispatchQueue.main.async {
if case let .success(newStatus, _) = response { if case let .success(newStatus, _) = response {
self.reblogged = newStatus.reblogged ?? false self.reblogged = newStatus.reblogged ?? false
self.mastodonController.persistentContainer.addOrUpdate(status: newStatus, incrementReferenceCount: false) self.mastodonController.persistentContainer.addOrUpdate(status: newStatus)
UIImpactFeedbackGenerator(style: .light).impactOccurred() UIImpactFeedbackGenerator(style: .light).impactOccurred()
} else { } else {
self.reblogged = oldValue self.reblogged = oldValue
@ -536,6 +531,7 @@ extension BaseStatusTableViewCell: UIDragInteractionDelegate {
} }
let provider = NSItemProvider(object: account.url as NSURL) let provider = NSItemProvider(object: account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID) let activity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }

View File

@ -112,7 +112,7 @@ extension ConversationMainStatusTableViewCell: UIContextMenuInteractionDelegate
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView)) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? [])
} }
} }
} }

View File

@ -14,6 +14,7 @@ import WebURLFoundationExtras
class StatusCardView: UIView { class StatusCardView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? weak var navigationDelegate: TuskerNavigationDelegate?
weak var actionProvider: MenuActionProvider?
var card: Card? { var card: Card? {
didSet { didSet {
@ -212,9 +213,6 @@ class StatusCardView: UIView {
} }
extension StatusCardView: MenuPreviewProvider {
}
extension StatusCardView: UIContextMenuInteractionDelegate { extension StatusCardView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let card = card else { return nil } guard let card = card else { return nil }
@ -222,7 +220,7 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
return SFSafariViewController(url: URL(card.url)!) return SFSafariViewController(url: URL(card.url)!)
} actionProvider: { (_) in } actionProvider: { (_) in
let actions = self.actionsForURL(URL(card.url)!, sourceView: self) let actions = self.actionProvider?.actionsForURL(URL(card.url)!, sourceView: self) ?? []
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
} }
} }

View File

@ -214,17 +214,6 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell {
reply() reply()
} }
override func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> BaseStatusTableViewCell.PreviewProviders? {
guard let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return (
content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) },
actions: { self.actionsForStatus(status, sourceView: self) }
)
}
// MARK: - Accessibility // MARK: - Accessibility
override var accessibilityLabel: String? { override var accessibilityLabel: String? {
@ -293,7 +282,7 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
return return
} }
completion(true) completion(true)
mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) mastodonController.persistentContainer.addOrUpdate(status: status)
} }
}) })
} }
@ -320,7 +309,7 @@ extension TimelineStatusTableViewCell: TableViewSwipeActionProvider {
return return
} }
completion(true) completion(true)
mastodonController.persistentContainer.addOrUpdate(status: status, incrementReferenceCount: false) mastodonController.persistentContainer.addOrUpdate(status: status)
} }
}) })
} }
@ -367,6 +356,7 @@ extension TimelineStatusTableViewCell: DraggableTableViewCell {
} }
let provider = NSItemProvider(object: status.url! as NSURL) let provider = NSItemProvider(object: status.url! as NSURL)
let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID) let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: accountID)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all) provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }
@ -377,13 +367,13 @@ extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration(identifier: nil) { return UIContextMenuConfiguration(identifier: nil) {
ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController) ProfileViewController(accountID: self.accountID, mastodonController: self.mastodonController)
} actionProvider: { (_) in } actionProvider: { (_) in
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView)) return UIMenu(title: "", image: nil, identifier: nil, options: [], children: self.delegate?.actionsForProfile(accountID: self.accountID, sourceView: self.avatarImageView) ?? [])
} }
} }
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController, if let viewController = animator.previewViewController,
let delegate = navigationDelegate { let delegate = delegate {
animator.preferredCommitStyle = .pop animator.preferredCommitStyle = .pop
animator.addCompletion { animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting { if let customPresenting = viewController as? CustomPreviewPresenting {
@ -396,3 +386,16 @@ extension TimelineStatusTableViewCell: UIContextMenuInteractionDelegate {
} }
} }
extension TimelineStatusTableViewCell: MenuPreviewProvider {
func getPreviewProviders(for location: CGPoint, sourceViewController: UIViewController) -> PreviewProviders? {
guard let mastodonController = mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return (
content: { ConversationTableViewController(for: self.statusID, state: self.statusState.copy(), mastodonController: mastodonController) },
actions: { self.delegate?.actionsForStatus(status, sourceView: self) ?? [] }
)
}
}

View File

@ -35,12 +35,14 @@ struct ToastConfiguration {
} }
extension ToastConfiguration { extension ToastConfiguration {
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping (ToastView) -> Void) { init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: ((ToastView) -> Void)?) {
self.init(title: title) self.init(title: title)
self.subtitle = error.localizedDescription self.subtitle = error.localizedDescription
self.systemImageName = error.systemImageName self.systemImageName = error.systemImageName
if let retryAction = retryAction {
self.actionTitle = "Retry" self.actionTitle = "Retry"
self.action = retryAction self.action = retryAction
}
self.longPressAction = { [unowned viewController] toast in self.longPressAction = { [unowned viewController] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
let text = """ let text = """
@ -55,6 +57,14 @@ extension ToastConfiguration {
viewController.present(reporter, animated: true) viewController.present(reporter, animated: true)
} }
} }
init(from error: Client.Error, with title: String, in viewController: UIViewController, retryAction: @escaping @MainActor (ToastView) async -> Void) {
self.init(from: error, with: title, in: viewController) { toast in
Task {
await retryAction(toast)
}
}
}
} }
fileprivate extension Client.Error { fileprivate extension Client.Error {

View File

@ -12,6 +12,7 @@ class ToastView: UIView {
let configuration: ToastConfiguration let configuration: ToastConfiguration
private var panRecognizer: UIPanGestureRecognizer!
private var shrinkAnimator: UIViewPropertyAnimator? private var shrinkAnimator: UIViewPropertyAnimator?
private var recognizedGesture = false private var recognizedGesture = false
private var handledLongPress = false private var handledLongPress = false
@ -101,9 +102,10 @@ class ToastView: UIView {
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
]) ])
let pan = UIPanGestureRecognizer(target: self, action: #selector(panRecognized)) panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
addGestureRecognizer(pan) addGestureRecognizer(panRecognizer)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized)) let longPress = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized))
longPress.delegate = self
addGestureRecognizer(longPress) addGestureRecognizer(longPress)
} }
@ -266,3 +268,11 @@ class ToastView: UIView {
} }
} }
extension ToastView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// if another recognizer can recognize simulatenously (e.g., table view cell drag initiation) it should require the toast one to fail
// otherwise long-pressing on a toast results in the drag beginning instead
return true
}
}

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
@MainActor
protocol ToastableViewController: UIViewController { protocol ToastableViewController: UIViewController {
var toastParentView: UIView { get } var toastParentView: UIView { get }

View File

@ -11,8 +11,8 @@ import SwiftUI
struct WrappedProgressView: UIViewRepresentable { struct WrappedProgressView: UIViewRepresentable {
typealias UIViewType = UIProgressView typealias UIViewType = UIProgressView
let value: Double let value: Int
let total: Double let total: Int
func makeUIView(context: Context) -> UIProgressView { func makeUIView(context: Context) -> UIProgressView {
return UIProgressView(progressViewStyle: .bar) return UIProgressView(progressViewStyle: .bar)
@ -20,7 +20,9 @@ struct WrappedProgressView: UIViewRepresentable {
func updateUIView(_ uiView: UIProgressView, context: Context) { func updateUIView(_ uiView: UIProgressView, context: Context) {
if total > 0 { if total > 0 {
uiView.setProgress(Float(value / total), animated: true) let progress = Float(value) / Float(total)
print(progress)
uiView.setProgress(progress, animated: true)
} else { } else {
uiView.setProgress(0, animated: true) uiView.setProgress(0, animated: true)
} }

View File

@ -61,6 +61,22 @@
<string>%u replies</string> <string>%u replies</string>
</dict> </dict>
</dict> </dict>
<key>expand threads inline count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@replies@</string>
<key>replies</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>u</string>
<key>one</key>
<string>1 more reply</string>
<key>other</key>
<string>%u more replies</string>
</dict>
</dict>
<key>favorites count</key> <key>favorites count</key>
<dict> <dict>
<key>NSStringLocalizedFormatKey</key> <key>NSStringLocalizedFormatKey</key>