Compare commits

..

14 Commits

Author SHA1 Message Date
Shadowfacts 8e570027a1 Bump build number and update changelog 2023-05-16 00:18:34 -04:00
Shadowfacts df9fb3c527 Fix Save Draft action not working 2023-05-16 00:06:58 -04:00
Shadowfacts 2080fdc955 Fix replied-to status not updating when selecting different draft 2023-05-16 00:04:30 -04:00
Shadowfacts 70f8748364 Fix crash if draft attachment lacks data 2023-05-16 00:03:54 -04:00
Shadowfacts 0343e2e310 Bump build number and update changelog 2023-05-16 00:01:28 -04:00
Shadowfacts 80645a089c Remove deleted statuses on notifications screen 2023-05-15 23:45:18 -04:00
Shadowfacts 37442bcb48 Fix crash if selected search scope somehow changes before the view is loaded 2023-05-15 23:45:18 -04:00
Shadowfacts a99072dd7c Fix crash if there are duplicate accounts in fav/reblog notification list 2023-05-15 23:45:18 -04:00
Shadowfacts 6b57ec8b97 Cleanup orphaned local attachments 2023-05-15 23:45:18 -04:00
Shadowfacts d84d402271 Fix various issues when dealing with multiple Compose/Drafts screens simultaneously 2023-05-15 22:57:07 -04:00
Shadowfacts f004c82302 Fix crash if TimelineGapCollectionViewCell is somehow accessibility-activated 2023-05-15 22:03:51 -04:00
Shadowfacts 126e8c8858 Resolve Mastodon remote status links
Closes #384
2023-05-15 22:01:44 -04:00
Shadowfacts dbc89509d7 Fix expand thread cell using wrong background color
Closes #383
2023-05-15 21:25:01 -04:00
Shadowfacts 0ba38e4a3a Fix handoff to iPad/Mac modally presenting new screen rather than pushing nav 2023-05-15 21:17:26 -04:00
23 changed files with 231 additions and 41 deletions

View File

@ -1,5 +1,17 @@
# Changelog # Changelog
## 2023.5 (96)
Features/Improvements:
- Resolve Mastodon's remote status links
Bugfixes:
- Fix handoff to iPad/Mac presenting new screen modally rather than navigating
- Fix crash if timeline gap cell is accessibility-activated after leaking
- Fix various crashes when multiple Compose/Drafts screens are opened
- Delete orphaned draft attachments
- Fix deleted posts not getting removed from Notifications screen
- Fix replied-to status not changing when selecting draft
## 2023.5 (94) ## 2023.5 (94)
Features/Improvements: Features/Improvements:
- Apply filters to Notifications screen - Apply filters to Notifications screen

View File

@ -112,6 +112,9 @@ class AttachmentThumbnailController: ViewController {
// } // }
} }
} }
case .none:
break
} }
} }

View File

@ -10,6 +10,7 @@ import Combine
import Pachyderm import Pachyderm
import TuskerComponents import TuskerComponents
import MatchedGeometryPresentation import MatchedGeometryPresentation
import CoreData
public final class ComposeController: ViewController { public final class ComposeController: ViewController {
public typealias FetchAttachment = (URL) async -> UIImage? public typealias FetchAttachment = (URL) async -> UIImage?
@ -54,6 +55,9 @@ public final class ComposeController: ViewController {
@Published public private(set) var didPostSuccessfully = false @Published public private(set) var didPostSuccessfully = false
@Published var hasChangedLanguageSelection = false @Published var hasChangedLanguageSelection = false
private var isDisappearing = false
private var userConfirmedDelete = false
var isPosting: Bool { var isPosting: Bool {
poster != nil poster != nil
} }
@ -119,6 +123,7 @@ public final class ComposeController: ViewController {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
} }
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
} }
public var view: some View { public var view: some View {
@ -129,6 +134,15 @@ public final class ComposeController: ViewController {
.environment(\.composeUIConfig, config) .environment(\.composeUIConfig, config)
} }
@MainActor
@objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
deleted.contains(where: { $0.objectID == self.draft.objectID }),
!isDisappearing {
self.config.dismiss(.cancel)
}
}
public func canPaste(itemProviders: [NSItemProvider]) -> Bool { public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else { guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
return false return false
@ -172,6 +186,7 @@ public final class ComposeController: ViewController {
@MainActor @MainActor
func cancel(deleteDraft: Bool) { func cancel(deleteDraft: Bool) {
deleteDraftOnDisappear = true deleteDraftOnDisappear = true
userConfirmedDelete = deleteDraft
config.dismiss(.cancel) config.dismiss(.cancel)
} }
@ -216,16 +231,18 @@ public final class ComposeController: ViewController {
} }
func selectDraft(_ newDraft: Draft) { func selectDraft(_ newDraft: Draft) {
if !self.draft.hasContent { let oldDraft = self.draft
DraftsPersistentContainer.shared.viewContext.delete(self.draft) self.draft = newDraft
if !oldDraft.hasContent {
DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
} }
DraftsPersistentContainer.shared.save() DraftsPersistentContainer.shared.save()
self.draft = newDraft
} }
func onDisappear() { func onDisappear() {
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully) { isDisappearing = true
if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
DraftsPersistentContainer.shared.viewContext.delete(draft) DraftsPersistentContainer.shared.viewContext.delete(draft)
} }
DraftsPersistentContainer.shared.save() DraftsPersistentContainer.shared.save()
@ -352,6 +369,8 @@ public final class ComposeController: ViewController {
rowTopInset: 8, rowTopInset: 8,
globalFrameOutsideList: globalFrameOutsideList globalFrameOutsideList: globalFrameOutsideList
) )
// i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
.id(id)
.listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowBackground(config.backgroundColor) .listRowBackground(config.backgroundColor)

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import TuskerComponents import TuskerComponents
import CoreData
class DraftsController: ViewController { class DraftsController: ViewController {
@ -152,11 +153,13 @@ private struct DraftRow: View {
Spacer() Spacer()
Text(draft.lastModified.formatted(.abbreviatedTimeAgo)) if let lastModified = draft.lastModified {
Text(lastModified.formatted(.abbreviatedTimeAgo))
.font(.body) .font(.body)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
} }
}
} }
private extension View { private extension View {

View File

@ -108,7 +108,7 @@ private struct DismissFocusedAttachmentButtonStyle: ButtonStyle {
} }
struct AttachmentDescriptionTextViewID: Hashable { struct AttachmentDescriptionTextViewID: Hashable {
let attachmentID: UUID let attachmentID: UUID!
init(_ attachment: DraftAttachment) { init(_ attachment: DraftAttachment) {
self.attachmentID = attachment.id self.attachmentID = attachment.id

View File

@ -28,7 +28,7 @@ public class Draft: NSManagedObject, Identifiable {
@NSManaged public var initialText: String @NSManaged public var initialText: String
@NSManaged public var inReplyToID: String? @NSManaged public var inReplyToID: String?
@NSManaged public var language: String? // ISO 639 language code @NSManaged public var language: String? // ISO 639 language code
@NSManaged public var lastModified: Date @NSManaged public var lastModified: Date!
@NSManaged public var localOnly: Bool @NSManaged public var localOnly: Bool
@NSManaged public var text: String @NSManaged public var text: String
@NSManaged private var visibilityStr: String @NSManaged private var visibilityStr: String

View File

@ -18,6 +18,10 @@ private let encoder = PropertyListEncoder()
@objc @objc
public final class DraftAttachment: NSManagedObject, Identifiable { public final class DraftAttachment: NSManagedObject, Identifiable {
@nonobjc public class func fetchRequest() -> NSFetchRequest<DraftAttachment> {
return NSFetchRequest<DraftAttachment>(entityName: "DraftAttachment")
}
@NSManaged internal var assetID: String? @NSManaged internal var assetID: String?
@NSManaged public var attachmentDescription: String @NSManaged public var attachmentDescription: String
@NSManaged internal private(set) var drawingData: Data? @NSManaged internal private(set) var drawingData: Data?
@ -26,7 +30,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
@NSManaged public var editedAttachmentURL: URL? @NSManaged public var editedAttachmentURL: URL?
@NSManaged public var fileURL: URL? @NSManaged public var fileURL: URL?
@NSManaged internal var fileType: String? @NSManaged internal var fileType: String?
@NSManaged public var id: UUID @NSManaged public var id: UUID!
@NSManaged internal var draft: Draft @NSManaged internal var draft: Draft
@ -54,7 +58,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
} else if let fileURL, let fileType { } else if let fileURL, let fileType {
return .file(fileURL, UTType(fileType)!) return .file(fileURL, UTType(fileType)!)
} else { } else {
fatalError() return .none
} }
} }
@ -72,6 +76,7 @@ public final class DraftAttachment: NSManagedObject, Identifiable {
case drawing(PKDrawing) case drawing(PKDrawing)
case file(URL, UTType) case file(URL, UTType)
case editing(String, Attachment.Kind, URL) case editing(String, Attachment.Kind, URL)
case none
} }
public override func prepareForDeletion() { public override func prepareForDeletion() {
@ -154,9 +159,13 @@ extension DraftAttachment: NSItemProviderReading {
return attachment return attachment
} }
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL { static var attachmentsDirectory: URL {
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
let directoryURL = containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments") return containerURL.appendingPathComponent("Documents").appendingPathComponent("attachments")
}
static func writeDataToFile(_ data: Data, id: UUID, type: UTType) throws -> URL {
let directoryURL = attachmentsDirectory
try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type) let attachmentURL = directoryURL.appendingPathComponent(id.uuidString, conformingTo: type)
try data.write(to: attachmentURL) try data.write(to: attachmentURL)
@ -238,6 +247,8 @@ extension DraftAttachment {
completion(.success((fileData, type))) completion(.success((fileData, type)))
} }
} }
} else {
completion(.failure(.noData))
} }
} }
@ -301,5 +312,6 @@ extension DraftAttachment {
case noVideoExportSession case noVideoExportSession
case loadingDrawing case loadingDrawing
case loadingData case loadingData
case noData
} }
} }

View File

@ -157,6 +157,29 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.attachments.add(draftAttachment) draft.attachments.add(draftAttachment)
} }
public func removeOrphanedAttachments(completion: @escaping () -> Void) {
guard let files = try? FileManager.default.contentsOfDirectory(at: DraftAttachment.attachmentsDirectory, includingPropertiesForKeys: nil),
!files.isEmpty else {
return
}
performBackgroundTask { context in
let allAttachmentsReq = DraftAttachment.fetchRequest()
allAttachmentsReq.predicate = NSPredicate(format: "fileURL != nil")
guard let allAttachments = try? context.fetch(allAttachmentsReq) else {
return
}
let orphaned = Set(files).subtracting(allAttachments.lazy.compactMap(\.fileURL))
for url in orphaned {
do {
try FileManager.default.removeItem(at: url)
} catch {
logger.error("Failed to remove orphaned attachment: \(String(describing: error), privacy: .public)")
}
}
completion()
}
}
@objc private func remoteChanges(_ notification: Foundation.Notification) { @objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else { guard let newHistoryToken = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return return

View File

@ -18,10 +18,6 @@ struct PollOptionView: View {
self.remove = remove self.remove = remove
} }
private var optionIndex: Int {
poll.options.index(of: option)
}
var body: some View { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor) Checkbox(radiusFraction: poll.multiple ? 0.1 : 0.5, background: controller.parent.config.backgroundColor)
@ -41,7 +37,8 @@ struct PollOptionView: View {
} }
private var textField: some View { private var textField: some View {
let placeholder = "Option \(optionIndex + 1)" let index = poll.options.index(of: option)
let placeholder = index != NSNotFound ? "Option \(index + 1)" : ""
let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars let maxLength = controller.parent.mastodonController.instanceFeatures.maxPollOptionChars
return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength) return EmojiTextField(text: $option.text, placeholder: placeholder, maxLength: maxLength)
} }

View File

@ -303,6 +303,7 @@
D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; }; D6D79F2F2A0D6A7F00AB2315 /* StatusEditPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */; };
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; }; D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */; };
D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */; }; D6D79F572A1160B800AB2315 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */; };
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D79F582A13293200AB2315 /* BackgroundManager.swift */; };
D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; }; D6D94955298963A900C59229 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D94954298963A900C59229 /* Colors.swift */; };
D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; }; D6D9498D298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */; };
D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; }; D6D9498F298EB79400C59229 /* CopyableLable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D9498E298EB79400C59229 /* CopyableLable.swift */; };
@ -704,6 +705,7 @@
D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = "<group>"; }; D6D79F2E2A0D6A7F00AB2315 /* StatusEditPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditPollView.swift; sourceTree = "<group>"; };
D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableButton.swift; sourceTree = "<group>"; }; D6D79F522A0FFE3200AB2315 /* ToggleableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleableButton.swift; sourceTree = "<group>"; };
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; }; D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = "<group>"; };
D6D79F582A13293200AB2315 /* BackgroundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundManager.swift; sourceTree = "<group>"; };
D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; }; D6D94954298963A900C59229 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; }; D6D9498C298CBB4000C59229 /* TrendingStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; }; D6D9498E298EB79400C59229 /* CopyableLable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyableLable.swift; sourceTree = "<group>"; };
@ -1476,6 +1478,7 @@
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */, D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */,
D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */, D61F75B6293C119700C0B37F /* Filterer.swift */,
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */, D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D61F75BA293C183100C0B37F /* HTMLConverter.swift */, D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
@ -2091,6 +2094,7 @@
D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */, D6C94D892139E6EC00CB5196 /* AttachmentView.swift in Sources */,
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */, D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */,
D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */, D6C94D872139E62700CB5196 /* LargeImageViewController.swift in Sources */,
D6D79F592A13293200AB2315 /* BackgroundManager.swift in Sources */,
D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */, D6E4269D2532A3E100C02E1C /* FuzzyMatcher.swift in Sources */,
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */, D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */,
D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */, D6F6A550291F058600F496A8 /* CreateListService.swift in Sources */,
@ -2378,7 +2382,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 = 94; CURRENT_PROJECT_VERSION = 96;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2444,7 +2448,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 = 94; CURRENT_PROJECT_VERSION = 96;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2470,7 +2474,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 94; CURRENT_PROJECT_VERSION = 96;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2499,7 +2503,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 94; CURRENT_PROJECT_VERSION = 96;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2528,7 +2532,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 94; CURRENT_PROJECT_VERSION = 96;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2683,7 +2687,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 = 94; CURRENT_PROJECT_VERSION = 96;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2714,7 +2718,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 = 94; CURRENT_PROJECT_VERSION = 96;
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2820,7 +2824,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 = 94; CURRENT_PROJECT_VERSION = 96;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2846,7 +2850,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 = 94; CURRENT_PROJECT_VERSION = 96;
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3; "IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;

View File

@ -76,6 +76,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
BackgroundManager.shared.registerHandlers()
return true return true
} }

View File

@ -0,0 +1,45 @@
//
// BackgroundManager.swift
// Tusker
//
// Created by Shadowfacts on 5/15/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
import BackgroundTasks
import OSLog
import ComposeUI
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "BackgroundManager")
struct BackgroundManager {
static let shared = BackgroundManager()
private init() {}
static let cleanupAttachmentsIdentifier = "\(Bundle.main.bundleIdentifier!).cleanup-attachments"
func scheduleTasks() {
BGTaskScheduler.shared.getPendingTaskRequests { requests in
if !requests.contains(where: { $0.identifier == Self.cleanupAttachmentsIdentifier }) {
let request = BGProcessingTaskRequest(identifier: Self.cleanupAttachmentsIdentifier)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
logger.error("Failed to schedule cleanup attachments: \(String(describing: error), privacy: .public)")
}
}
}
}
func registerHandlers() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.cleanupAttachmentsIdentifier, using: nil, launchHandler: handleCleanupAttachments(_:))
}
private func handleCleanupAttachments(_ task: BGTask) {
DraftsPersistentContainer.shared.removeOrphanedAttachments() {
task.setTaskCompleted(success: true)
}
}
}

View File

@ -2,8 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ITSAppUsesNonExemptEncryption</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
<false/> <array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).cleanup-attachments</string>
</array>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
@ -33,6 +35,8 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string> <string>public.app-category.social-networking</string>
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
@ -141,6 +145,7 @@
</dict> </dict>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>processing</string>
<string>remote-notification</string> <string>remote-notification</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View File

@ -159,6 +159,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
try? context.save() try? context.save()
} }
BackgroundManager.shared.scheduleTasks()
} }
func showAppOrOnboardingUI(session: UISceneSession? = nil) { func showAppOrOnboardingUI(session: UISceneSession? = nil) {

View File

@ -11,6 +11,13 @@ import Pachyderm
import WebURL import WebURL
import WebURLFoundationExtras import WebURLFoundationExtras
private let mastodonRemoteStatusRegex = try! NSRegularExpression(pattern: "^/@.+@.+/\\d{18}")
private func isLikelyMastodonRemoteStatus(url: URL) -> Bool {
let path = url.path
let range = NSRange(location: 0, length: path.utf16.count)
return mastodonRemoteStatusRegex.numberOfMatches(in: path, range: range) == 1
}
class ConversationViewController: UIViewController { class ConversationViewController: UIViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
@ -210,11 +217,24 @@ class ConversationViewController: UIViewController {
indicator.startAnimating() indicator.startAnimating()
state = .loading(indicator) state = .loading(indicator)
let url = WebURL(url)!.serialized(excludingFragment: true) let effectiveURL: String
let request = Client.search(query: url, types: [.statuses], resolve: true) class RedirectBlocker: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
if isLikelyMastodonRemoteStatus(url: url),
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
} else {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
}
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
do { do {
let (results, _) = try await mastodonController.run(request) let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else { guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else {
throw UnableToResolveError() throw UnableToResolveError()
} }
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)

View File

@ -144,7 +144,15 @@ class ExpandThreadCollectionViewCell: UICollectionViewListCell {
} }
override func updateConfiguration(using state: UICellConfigurationState) { override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state) var config = UIBackgroundConfiguration.listGroupedCell().updated(for: state)
if state.isFocused {
// use default
} else if state.isHighlighted || state.isSelected {
config.backgroundColor = .appSelectedCellBackground
} else {
config.backgroundColor = .appSecondaryBackground
}
backgroundConfiguration = config
} }
} }

View File

@ -115,6 +115,14 @@ class MainSplitViewController: UISplitViewController {
} }
} }
override func show(_ vc: UIViewController, sender: Any?) {
if traitCollection.horizontalSizeClass == .regular {
secondaryNavController.show(vc, sender: sender)
} else {
super.show(vc, sender: sender)
}
}
@objc func handleSidebarCommandTimelines() { @objc func handleSidebarCommandTimelines() {
sidebar.select(item: .tab(.timelines), animated: false) sidebar.select(item: .tab(.timelines), animated: false)
select(item: .tab(.timelines)) select(item: .tab(.timelines))

View File

@ -120,6 +120,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
filterer.filtersChanged = { [unowned self] actionsChanged in filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged) self.reapplyFilters(actionsChanged: actionsChanged)
} }
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -253,6 +255,24 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
dataSource.apply(snapshot) dataSource.apply(snapshot)
} }
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = dataSource.snapshot()
let items = snapshot.itemIdentifiers(inSection: .notifications)
let toDelete = statusIDs.flatMap { id in
items.lazy.filter { $0.group?.notifications.first?.status?.id == id }
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
private func dismissNotificationsInGroup(at indexPath: IndexPath) async { private func dismissNotificationsInGroup(at indexPath: IndexPath) async {
guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else { guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else {
return return

View File

@ -180,6 +180,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
} }
func performSearch(query: String?) { func performSearch(query: String?) {
guard isViewLoaded else {
return
}
guard let query = query, !query.isEmpty else { guard let query = query, !query.isEmpty else {
self.dataSource.apply(NSDiffableDataSourceSnapshot<Section, Item>()) self.dataSource.apply(NSDiffableDataSourceSnapshot<Section, Item>())
return return

View File

@ -63,7 +63,7 @@ class StatusActionAccountListViewController: UIViewController {
self.actionType = actionType self.actionType = actionType
self.statusID = statusID self.statusID = statusID
self.statusState = statusState self.statusState = statusState
self.accountIDs = accountIDs self.accountIDs = accountIDs?.uniques()
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }

View File

@ -135,11 +135,12 @@ class TimelineGapCollectionViewCell: UICollectionViewCell {
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
get { get {
[TimelineGapDirection.below, .above].map { dir in [TimelineGapDirection.below, .above].map { dir in
UIAccessibilityCustomAction(name: "Load \(dir.accessibilityLabel)") { [unowned self] _ in UIAccessibilityCustomAction(name: "Load \(dir.accessibilityLabel)") { [weak self] _ in
guard let self else { return false }
Task { Task {
showsIndicator = true self.showsIndicator = true
await fillGap?(dir) await self.fillGap?(dir)
showsIndicator = false self.showsIndicator = false
} }
return true return true
} }

View File

@ -305,9 +305,11 @@ private class SplitSecondaryNavigationController: EnhancedNavigationViewControll
} }
override func pushViewController(_ viewController: UIViewController, animated: Bool) { override func pushViewController(_ viewController: UIViewController, animated: Bool) {
super.pushViewController(viewController, animated: animated) if viewControllers.isEmpty {
configureSecondarySplitCloseButton(for: viewController)
}
configureSecondarySplitCloseButton(for: viewControllers.first!) super.pushViewController(viewController, animated: animated)
} }
private func configureSecondarySplitCloseButton(for viewController: UIViewController) { private func configureSecondarySplitCloseButton(for viewController: UIViewController) {

View File

@ -205,6 +205,7 @@ enum PopoverSource {
private let statusPathRegex = try! NSRegularExpression( private let statusPathRegex = try! NSRegularExpression(
pattern: pattern:
"(^/@[a-z0-9_]+/\\d{18})" // mastodon "(^/@[a-z0-9_]+/\\d{18})" // mastodon
+ "|(^/@.+@.+/\\d{18})" // mastodon remote
+ "|(^/notice/[a-z0-9]{18})" // pleroma + "|(^/notice/[a-z0-9]{18})" // pleroma
+ "|(^/notes/[a-z0-9]{10})" // misskey + "|(^/notes/[a-z0-9]{10})" // misskey
+ "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed + "|(^/p/[a-z0-9_]+/\\d{18})" // pixelfed