Compare commits

..

No commits in common. "5a4323067ad63772fb9052a4e3aebe69d7305be3" and "53611d80d6e5e90eac1c3c114c1e38fb3830a845" have entirely different histories.

114 changed files with 1321 additions and 2790 deletions

View File

@ -1,48 +1,3 @@
## 2023.8
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
Features/Improvements:
- Show search operators on Mastodon 4.2
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Allow changing list reply policy and exclusivity options on Edit List screen
- Add Translate action to conversations (on supported Mastodon instances)
- Style block quotes correclty in rich-text posts
- Improve the appearance of lists in rich-text posts
- Add preference to underline links
- Compress uploaded video attachments to fit within instance limits
- Add preference to hide attachments in timelines
- Update visible timestamps after refresh notifications/timelines
- iPadOS: Allow switching between split screen and fullscreen navigation modes
- Pixelfed: Improve error message when uploading attachment fails
- Akkoma: Enable composing local-only posts
Bugfixes:
- Fix older notifications not loading if all initiially-loaded ones are grouped together
- Fix List timelines failing to refresh if they were initially empty
- Fix replies to posts with CWs always showing confirmation dialog when cancelling
- Fix Compose screen permitting setting the language to multiple/undefined
- Fix crash when uploading attachments without file extensions
- Fix Live Text button reappearing with swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- Fix avatars on follow request notifications not being rounded
- Fix timeline jump button appearing incorrectly when Button Shapes acccessibility setting is on
- Fix public instance timeline screen not handling post deletion correctly
- Fix post that's reblogged and contains a followed hashtag not showing the reblogger
- Fix crash on launch when reblogged posts are visible
- Fix crash when showing display names with custom emoji in certain places
- Fix crash when showing trending hashtags without history data
- Fix potential crash on instance selector screen
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix potential crash after deleting List on the Eplore screen
- Pixelfed: Fix error decoding certain posts
- VoiceOver: Fix history entries on Edit History screen not having descriptions
- iPadOS: Fix delay on app launch before "My Profile" sidebar item appears
- iPadOS: Fix language picker button not highlighting when hovered with the cursor
- macOS: Fix "New Post" window title appearing twice
- macOS: Fix Cmd+W sometimes closing non-foreground windows
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.7 ## 2023.7
This update adds support for iOS 17 and includes some minor changes. This update adds support for iOS 17 and includes some minor changes.

View File

@ -1,75 +1,5 @@
# Changelog # Changelog
## 2023.8 (110)
Bugfixes:
- Fix potential crash after deleting List on Explore screen
## 2023.8 (109)
Features/Improvements:
- Add Translate action to conversations (on supported Mastodon instances)
- Improve share extension launch speed
- Add preference for hiding attachments in timelines
Bugfixes:
- Fix crash during state restoration when reblogged statuses are present
- Fix timeline state restoration using incorrect scroll position in certain circumstances
- Fix status that is reblogged and contains a followed hashtag not showing reblogger label
- macOS: Fix visibility/local-only buttons not appearing in Compose toolbar
- macOS: Fix images copied from Safari not pasting on Compose screen
## 2023.8 (107)
Features/Improvements:
- Style blockquotes in statuses
- Use server language preference for search operator suggestions
- Render IDN domains in the account switcher
Bugfixes:
- Fix crash when showing trending hashtags with improper history data
- Fix crash when uploading attachment w/o file extension
- Fix status deletions not being handled properly in logged out views
- Fix status history entries not having VoiceOver descriptions
- Fix avatars in follow request notifications not being rounded
- Fix potential crash if the app is dismissed while fast account switcher is animating
- Fix error decoding certain statuses on Pixelfed
## 2023.8 (106)
Bugfixes:
- Fix being able to set post language to multiple/undefined
- iPadOS: Fix language picker button not having a pointer effect
- macOS: Fix Cmd+W sometimes closing the non-foreground window
## 2023.8 (105)
Features/Improvements:
- Use server-set preference for default post visibility, language, and (on Hometown) local-only
- Add preference to underline links
- Allow changing list reply policy and exclusivity from menu on Edit List screen
- Attribute network requests to user, rather than developer, when appropriate
Bugfixes:
- Fix older notifications not loading if all initially-loaded are grouped together
- Fix list timelines failing to refresh if there were no statuses initially
- Fix timeline jump button having a background when Button Shapes accessibility setting is on
- Fix crash when relaunching app after not being launched in more than a week
- Fix potential crash on instance selector screen
- Fix crash when showing display names with custom emojis in certain places
## 2023.8 (104)
Features/Improvements:
- Show search operators on Mastodon 4.2
- Enable composing local-only posts on Akkoma
- Update timestamps after refreshing notifications/timelines
- Improve list appearance in rich text posts
- Improve error message when uploading attachment to Pixelfed fails
- Compress uploaded videos to fit within instance limits
- iPad: Allow switching between split screen and full screen navigation
Bugfixes:
- Fix replies to posts with content warnings always showing confirmation dialog before closing
- Fix Live Text control reappearing when swiping between attachment gallery pages
- Fix avatars on certain notifications flickering when refreshing
- iPad: Fix delay on app launch before "My Profile" sidebar item appears
- macOS: Fix "New Post" window title appearing twice
## 2023.7 (103) ## 2023.7 (103)
Features/Improvements: Features/Improvements:
- Add support for iOS 17 - Add support for iOS 17

View File

@ -72,12 +72,12 @@ class PostService: ObservableObject {
mediaIDs: uploadedAttachments, mediaIDs: uploadedAttachments,
sensitive: sensitive, sensitive: sensitive,
spoilerText: contentWarning, spoilerText: contentWarning,
visibility: draft.localOnly && mastodonController.instanceFeatures.localOnlyPostsVisibility ? Status.localPostVisibility : draft.visibility.rawValue, visibility: draft.visibility,
language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil, language: mastodonController.instanceFeatures.createStatusWithLanguage ? draft.language : nil,
pollOptions: draft.poll?.pollOptions.map(\.text), pollOptions: draft.poll?.pollOptions.map(\.text),
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration), pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
pollMultiple: draft.poll?.multiple, pollMultiple: draft.poll?.multiple,
localOnly: mastodonController.instanceFeatures.localOnlyPosts && !mastodonController.instanceFeatures.localOnlyPostsVisibility ? draft.localOnly : nil, localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil,
idempotencyKey: draft.id.uuidString idempotencyKey: draft.id.uuidString
) )
} }
@ -111,12 +111,16 @@ class PostService: ObservableObject {
do { do {
(data, utType) = try await getData(for: attachment) (data, utType) = try await getData(for: attachment)
currentStep += 1 currentStep += 1
} catch let error as DraftAttachment.ExportError { } catch let error as AttachmentData.Error {
throw Error.attachmentData(index: index, cause: error) throw Error.attachmentData(index: index, cause: error)
} }
let uploaded = try await uploadAttachment(index: index, data: data, utType: utType, description: attachment.attachmentDescription) do {
attachments.append(uploaded.id) let uploaded = try await uploadAttachment(data: data, utType: utType, description: attachment.attachmentDescription)
currentStep += 1 attachments.append(uploaded.id)
currentStep += 1
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
} }
return attachments return attachments
} }
@ -134,21 +138,10 @@ class PostService: ObservableObject {
} }
} }
private func uploadAttachment(index: Int, data: Data, utType: UTType, description: String?) async throws -> Attachment { private func uploadAttachment(data: Data, utType: UTType, description: String?) async throws -> Attachment {
guard let mimeType = utType.preferredMIMEType else { let formAttachment = FormAttachment(mimeType: utType.preferredMIMEType!, data: data, fileName: "file.\(utType.preferredFilenameExtension!)")
throw Error.attachmentMissingMimeType(index: index, type: utType)
}
var filename = "file"
if let ext = utType.preferredFilenameExtension {
filename.append(".\(ext)")
}
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: filename)
let req = Client.upload(attachment: formAttachment, description: description) let req = Client.upload(attachment: formAttachment, description: description)
do { return try await mastodonController.run(req).0
return try await mastodonController.run(req).0
} catch let error as Client.Error {
throw Error.attachmentUpload(index: index, cause: error)
}
} }
private func textForPosting() -> String { private func textForPosting() -> String {
@ -176,8 +169,7 @@ class PostService: ObservableObject {
} }
enum Error: Swift.Error, LocalizedError { enum Error: Swift.Error, LocalizedError {
case attachmentData(index: Int, cause: DraftAttachment.ExportError) case attachmentData(index: Int, cause: AttachmentData.Error)
case attachmentMissingMimeType(index: Int, type: UTType)
case attachmentUpload(index: Int, cause: Client.Error) case attachmentUpload(index: Int, cause: Client.Error)
case posting(Client.Error) case posting(Client.Error)
@ -185,8 +177,6 @@ class PostService: ObservableObject {
switch self { switch self {
case let .attachmentData(index: index, cause: cause): case let .attachmentData(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)" return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .attachmentMissingMimeType(index: index, type: type):
return "Attachment \(index + 1): unknown MIME type for \(type.identifier)"
case let .attachmentUpload(index: index, cause: cause): case let .attachmentUpload(index: index, cause: cause):
return "Attachment \(index + 1): \(cause.localizedDescription)" return "Attachment \(index + 1): \(cause.localizedDescription)"
case let .posting(error): case let .posting(error):

View File

@ -20,11 +20,7 @@ public final class ComposeController: ViewController {
public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
public typealias EmojiImageView = (Emoji) -> AnyView public typealias EmojiImageView = (Emoji) -> AnyView
@Published public private(set) var draft: Draft { @Published public private(set) var draft: Draft
didSet {
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
}
}
@Published public var config: ComposeUIConfig @Published public var config: ComposeUIConfig
@Published public var mastodonController: ComposeMastodonContext @Published public var mastodonController: ComposeMastodonContext
let fetchAvatar: AvatarImageView.FetchAvatar let fetchAvatar: AvatarImageView.FetchAvatar
@ -110,7 +106,6 @@ public final class ComposeController: ViewController {
emojiImageView: @escaping EmojiImageView emojiImageView: @escaping EmojiImageView
) { ) {
self.draft = draft self.draft = draft
assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
self.config = config self.config = config
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.fetchAvatar = fetchAvatar self.fetchAvatar = fetchAvatar

View File

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

View File

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

View File

@ -137,8 +137,6 @@ extension DraftAttachment {
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment" //private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
private let imageType = UTType.image.identifier private let imageType = UTType.image.identifier
private let heifType = UTType.heif.identifier
private let heicType = UTType.heic.identifier
private let jpegType = UTType.jpeg.identifier private let jpegType = UTType.jpeg.identifier
private let pngType = UTType.png.identifier private let pngType = UTType.png.identifier
private let mp4Type = UTType.mpeg4Movie.identifier private let mp4Type = UTType.mpeg4Movie.identifier
@ -150,18 +148,17 @@ extension DraftAttachment: NSItemProviderReading {
// todo: is there a better way of handling movies than manually adding all possible UTI types? // todo: is there a better way of handling movies than manually adding all possible UTI types?
// just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension // just using kUTTypeMovie doesn't work, because we need the actually type in order to get the file extension
// without the file extension, getting the thumbnail and exporting the video for attachment upload fails // without the file extension, getting the thumbnail and exporting the video for attachment upload fails
[/*typeIdentifier, */ gifType, heifType, heicType, jpegType, pngType, imageType, mp4Type, quickTimeType] [/*typeIdentifier, */ gifType, jpegType, pngType, imageType, mp4Type, quickTimeType]
} }
public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment { public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DraftAttachment {
var data = data var data = data
var type = UTType(typeIdentifier)! var type = UTType(typeIdentifier)!
// the type is .image in certain circumstances: // this seems to only occur when the item is a UIImage, rather than just image data,
// - macOS: image copied from macOS Safari -> only UIImage(data: data) works // which seems to only occur when sharing a screenshot directly from the markup screen
// - iOS: sharing screenshot from markup -> only NSKeyedUnarchiver works
if type == .image, if type == .image,
let image = UIImage(data: data) ?? (try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data)), let image = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data),
let pngData = image.pngData() { let pngData = image.pngData() {
data = pngData data = pngData
type = .png type = .png
@ -219,7 +216,7 @@ extension DraftAttachment {
options.isNetworkAccessAllowed = true options.isNetworkAccessAllowed = true
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { exportSession, info in
if let exportSession { if let exportSession {
Self.exportVideoData(session: exportSession, features: features, completion: completion) Self.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error { } else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error))) completion(.failure(.videoExport(error)))
} else { } else {
@ -245,7 +242,7 @@ extension DraftAttachment {
completion(.failure(.noVideoExportSession)) completion(.failure(.noVideoExportSession))
return return
} }
Self.exportVideoData(session: session, features: features, completion: completion) Self.exportVideoData(session: session, completion: completion)
} else { } else {
let fileData: Data let fileData: Data
do { do {
@ -276,13 +273,20 @@ extension DraftAttachment {
var data = data var data = data
var type = type var type = type
if type != .png && type != .jpeg,
let image = UIImage(data: data) {
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let image = CIImage(data: data)! let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG // neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB) // they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion // if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic || type == .heif { if needsColorSpaceConversion || type == .heic {
let context = CIContext() let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace! let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png { if type == .png {
@ -296,12 +300,9 @@ extension DraftAttachment {
return (data, type) return (data, type)
} }
private static func exportVideoData(session: AVAssetExportSession, features: InstanceFeatures, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) { private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), ExportError>) -> Void) {
session.outputFileType = .mp4 session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4") session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
if let configuration = features.mediaAttachmentsConfiguration {
session.fileLengthLimit = Int64(configuration.videoSizeLimit)
}
session.exportAsynchronously { session.exportAsynchronously {
guard session.status == .completed else { guard session.status == .completed else {
completion(.failure(.videoExport(session.error!))) completion(.failure(.videoExport(session.error!)))

View File

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

View File

@ -81,7 +81,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
contentWarning: String, contentWarning: String,
inReplyToID: String?, inReplyToID: String?,
visibility: Visibility, visibility: Visibility,
language: String?,
localOnly: Bool localOnly: Bool
) -> Draft { ) -> Draft {
let draft = Draft(context: viewContext) let draft = Draft(context: viewContext)
@ -89,11 +88,9 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.text = text draft.text = text
draft.initialText = text draft.initialText = text
draft.contentWarning = contentWarning draft.contentWarning = contentWarning
draft.initialContentWarning = contentWarning
draft.contentWarningEnabled = !contentWarning.isEmpty draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID draft.inReplyToID = inReplyToID
draft.visibility = visibility draft.visibility = visibility
draft.language = language
draft.localOnly = localOnly draft.localOnly = localOnly
save() save()
return draft return draft
@ -115,7 +112,6 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.initialText = source.text draft.initialText = source.text
draft.contentWarning = source.spoilerText draft.contentWarning = source.spoilerText
draft.contentWarningEnabled = !source.spoilerText.isEmpty draft.contentWarningEnabled = !source.spoilerText.isEmpty
draft.initialContentWarning = source.spoilerText
draft.inReplyToID = inReplyToID draft.inReplyToID = inReplyToID
draft.visibility = visibility draft.visibility = visibility
draft.localOnly = localOnly draft.localOnly = localOnly

View File

@ -0,0 +1,278 @@
//
// AttachmentData.swift
// ComposeUI
//
// Created by Shadowfacts on 1/1/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Photos
import UniformTypeIdentifiers
import PencilKit
import InstanceFeatures
enum AttachmentData {
case asset(PHAsset)
case image(Data, originalType: UTType)
case video(URL)
case drawing(PKDrawing)
case gif(Data)
var type: AttachmentType {
switch self {
case let .asset(asset):
return asset.attachmentType!
case .image(_, originalType: _):
return .image
case .video(_):
return .video
case .drawing(_):
return .image
case .gif(_):
return .image
}
}
var isAsset: Bool {
switch self {
case .asset(_):
return true
default:
return false
}
}
var canSaveToDraft: Bool {
switch self {
case .video(_):
return false
default:
return true
}
}
func getData(features: InstanceFeatures, skipAllConversion: Bool = false, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
switch self {
case let .image(originalData, originalType):
let data: Data
let type: UTType
switch originalType {
case .png, .jpeg:
data = originalData
type = originalType
default:
let image = UIImage(data: originalData)!
// The quality of 0.8 was chosen completely arbitrarily, it may need to be tuned in the future.
data = image.jpegData(compressionQuality: 0.8)!
type = .jpeg
}
let processed = processImageData(data, type: type, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
case let .asset(asset):
if asset.mediaType == .image {
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
guard let data = data, let dataUTI = dataUTI else {
completion(.failure(.missingData))
return
}
let processed = processImageData(data, type: UTType(dataUTI)!, features: features, skipAllConversion: skipAllConversion)
completion(.success(processed))
}
} else if asset.mediaType == .video {
let options = PHVideoRequestOptions()
options.deliveryMode = .automatic
options.isNetworkAccessAllowed = true
options.version = .current
PHImageManager.default().requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetHighestQuality) { (exportSession, info) in
if let exportSession = exportSession {
AttachmentData.exportVideoData(session: exportSession, completion: completion)
} else if let error = info?[PHImageErrorKey] as? Error {
completion(.failure(.videoExport(error)))
} else {
completion(.failure(.noVideoExportSession))
}
}
} else {
fatalError("assetType must be either image or video")
}
case let .video(url):
let asset = AVURLAsset(url: url)
guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) else {
completion(.failure(.noVideoExportSession))
return
}
AttachmentData.exportVideoData(session: session, completion: completion)
case let .drawing(drawing):
let image = drawing.imageInLightMode(from: drawing.bounds, scale: 1)
completion(.success((image.pngData()!, .png)))
case let .gif(data):
completion(.success((data, .gif)))
}
}
private func processImageData(_ data: Data, type: UTType, features: InstanceFeatures, skipAllConversion: Bool) -> (Data, UTType) {
guard !skipAllConversion else {
return (data, type)
}
var data = data
var type = type
let image = CIImage(data: data)!
let needsColorSpaceConversion = features.needsWideColorGamutHack && image.colorSpace?.name != CGColorSpace.sRGB
// neither Mastodon nor Pleroma handles HEIC well, so convert to JPEG
// they also do a bad job converting wide color gamut images (they seem to just drop the profile, letting the wide-gamut values be reinterprete as sRGB)
// if that changes in the future, we'll need to pass the InstanceFeatures in here somehow and gate the conversion
if needsColorSpaceConversion || type == .heic {
let context = CIContext()
let colorSpace = needsColorSpaceConversion || image.colorSpace != nil ? CGColorSpace(name: CGColorSpace.sRGB)! : image.colorSpace!
if type == .png {
data = context.pngRepresentation(of: image, format: .ARGB8, colorSpace: colorSpace)!
} else {
data = context.jpegRepresentation(of: image, colorSpace: colorSpace)!
type = .jpeg
}
}
return (data, type)
}
private static func exportVideoData(session: AVAssetExportSession, completion: @escaping (Result<(Data, UTType), Error>) -> Void) {
session.outputFileType = .mp4
session.outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("exported_video_\(UUID())").appendingPathExtension("mp4")
session.exportAsynchronously {
guard session.status == .completed else {
completion(.failure(.videoExport(session.error!)))
return
}
do {
let data = try Data(contentsOf: session.outputURL!)
completion(.success((data, .mpeg4Movie)))
} catch {
completion(.failure(.videoExport(error)))
}
}
}
enum AttachmentType {
case image, video
}
enum Error: Swift.Error, LocalizedError {
case missingData
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"
}
}
}
}
extension PHAsset {
var attachmentType: AttachmentData.AttachmentType? {
switch self.mediaType {
case .image:
return .image
case .video:
return .video
default:
return nil
}
}
}
extension AttachmentData: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .asset(asset):
try container.encode("asset", forKey: .type)
try container.encode(asset.localIdentifier, forKey: .assetIdentifier)
case let .image(originalData, originalType):
try container.encode("image", forKey: .type)
try container.encode(originalType, forKey: .imageType)
try container.encode(originalData, forKey: .imageData)
case .video(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "video CompositionAttachments cannot be encoded"))
case let .drawing(drawing):
try container.encode("drawing", forKey: .type)
let drawingData = drawing.dataRepresentation()
try container.encode(drawingData, forKey: .drawing)
case .gif(_):
throw EncodingError.invalidValue(self, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "gif CompositionAttachments cannot be encoded"))
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
switch try container.decode(String.self, forKey: .type) {
case "asset":
let identifier = try container.decode(String.self, forKey: .assetIdentifier)
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil).firstObject else {
throw DecodingError.dataCorruptedError(forKey: .assetIdentifier, in: container, debugDescription: "Could not fetch asset with local identifier")
}
self = .asset(asset)
case "image":
let data = try container.decode(Data.self, forKey: .imageData)
if let type = try container.decodeIfPresent(UTType.self, forKey: .imageType) {
self = .image(data, originalType: type)
} else {
guard let image = UIImage(data: data) else {
throw DecodingError.dataCorruptedError(forKey: .imageData, in: container, debugDescription: "CompositionAttachment data could not be decoded into UIImage")
}
let jpegData = image.jpegData(compressionQuality: 1)!
self = .image(jpegData, originalType: .jpeg)
}
case "drawing":
let drawingData = try container.decode(Data.self, forKey: .drawing)
let drawing = try PKDrawing(data: drawingData)
self = .drawing(drawing)
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "CompositionAttachment type must be one of image, asset, or drawing")
}
}
enum CodingKeys: CodingKey {
case type
case imageData
case imageType
/// The local identifier of the PHAsset for this attachment
case assetIdentifier
/// The PKDrawing object for this attachment.
case drawing
}
}
extension AttachmentData: Equatable {
static func ==(lhs: AttachmentData, rhs: AttachmentData) -> Bool {
switch (lhs, rhs) {
case let (.asset(a), .asset(b)):
return a.localIdentifier == b.localIdentifier
case let (.image(a, originalType: aType), .image(b, originalType: bType)):
return a == b && aType == bType
case let (.video(a), .video(b)):
return a == b
case let (.drawing(a), .drawing(b)):
return a == b
default:
return false
}
}
}

View File

@ -30,13 +30,7 @@ struct LanguagePicker: View {
if maybeIso639Code.last == "-" { if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)] maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
} }
let identifier = String(maybeIso639Code) let code = Locale.LanguageCode(String(maybeIso639Code))
// mul (for multiple languages) and unk (unknown) are ISO codes, but not ones that akkoma permits, so we ignore them on all platforms
guard identifier != "mul",
identifier != "und" else {
return nil
}
let code = Locale.LanguageCode(identifier)
if code.isISOLanguage { if code.isISOLanguage {
return code return code
} else { } else {
@ -45,10 +39,13 @@ struct LanguagePicker: View {
} }
private var codeFromPreferredLanguages: Locale.LanguageCode? { private var codeFromPreferredLanguages: Locale.LanguageCode? {
if let identifier = Locale.preferredLanguages.first, if let identifier = Locale.preferredLanguages.first {
case let code = Locale.LanguageCode(identifier), let code = Locale.LanguageCode(identifier)
code.isISOLanguage { if code.isISOLanguage {
return code return code
} else {
return nil
}
} else { } else {
return nil return nil
} }
@ -69,8 +66,6 @@ struct LanguagePicker: View {
Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased()) Text((languageCode.wrappedValue.identifier(.alpha2) ?? languageCode.wrappedValue.identifier).uppercased())
} }
.accessibilityLabel("Post Language") .accessibilityLabel("Post Language")
.padding(5)
.hoverEffect()
.sheet(isPresented: $isShowingSheet) { .sheet(isPresented: $isShowingSheet) {
NavigationStack { NavigationStack {
LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet) LanguagePickerList(languageCode: languageCode, hasChangedSelection: $hasChangedSelection, isPresented: $isShowingSheet)
@ -143,12 +138,10 @@ private struct LanguagePickerList: View {
// make sure recents always contains the currently selected lang // make sure recents always contains the currently selected lang
let recents = addRecentLang(languageCode) let recents = addRecentLang(languageCode)
recentLangs = recents recentLangs = recents
.filter { $0 != "mul" && $0 != "und" }
.map { Lang(code: .init($0)) } .map { Lang(code: .init($0)) }
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
langs = Locale.LanguageCode.isoLanguageCodes langs = Locale.LanguageCode.isoLanguageCodes
.filter { $0.identifier != "mul" && $0.identifier != "und" }
.map { Lang(code: $0) } .map { Lang(code: $0) }
.sorted { $0.name < $1.name } .sorted { $0.name < $1.name }
} }

View File

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

View File

@ -9,39 +9,26 @@ import Foundation
import Pachyderm import Pachyderm
public struct InstanceInfo { public struct InstanceInfo {
public var version: String public let version: String
public var maxStatusCharacters: Int? public let maxStatusCharacters: Int?
public var configuration: InstanceV1.Configuration? public let configuration: Instance.Configuration?
public var pollsConfiguration: InstanceV1.PollsConfiguration? public let pollsConfiguration: Instance.PollsConfiguration?
public var translation: Bool
public init( public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) {
version: String,
maxStatusCharacters: Int?,
configuration: InstanceV1.Configuration?,
pollsConfiguration: InstanceV1.PollsConfiguration?,
translation: Bool
) {
self.version = version self.version = version
self.maxStatusCharacters = maxStatusCharacters self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration self.configuration = configuration
self.pollsConfiguration = pollsConfiguration self.pollsConfiguration = pollsConfiguration
self.translation = translation
} }
} }
extension InstanceInfo { extension InstanceInfo {
public init(v1 instance: InstanceV1) { public init(instance: Instance) {
self.init( self.init(
version: instance.version, version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters, maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration, configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration, pollsConfiguration: instance.pollsConfiguration
translation: false
) )
} }
public mutating func update(v2: InstanceV2) {
translation = v2.configuration.translation.enabled
}
} }

View File

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

View File

@ -6,7 +6,7 @@ import PackageDescription
let package = Package( let package = Package(
name: "Pachyderm", name: "Pachyderm",
platforms: [ platforms: [
.iOS(.v15), .iOS(.v14),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, and make them visible to other packages. // Products define the executables and libraries a package produces, and make them visible to other packages.

View File

@ -105,20 +105,6 @@ public class Client {
return task return task
} }
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
return try await withCheckedThrowingContinuation { continuation in
run(request) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
case .success(let result, let pagination):
continuation.resume(returning: (result, pagination))
}
}
}
}
func createURLRequest<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.endpoint.path components.path = request.endpoint.path
@ -127,7 +113,6 @@ public class Client {
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval) var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
urlRequest.httpMethod = request.method.name urlRequest.httpMethod = request.method.name
urlRequest.httpBody = request.body.data urlRequest.httpBody = request.body.data
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
for (name, value) in request.headers { for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name) urlRequest.setValue(value, forHTTPHeaderField: name)
} }
@ -136,8 +121,6 @@ public class Client {
} }
if let accessToken = accessToken { if let accessToken = accessToken {
urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
// We consider authenticated requests to be user-initiated.
urlRequest.attribution = .user
} }
return urlRequest return urlRequest
} }
@ -231,22 +214,14 @@ public class Client {
return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts) return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
} }
public static func getInstanceV1() -> Request<InstanceV1> { public static func getInstance() -> Request<Instance> {
return Request<InstanceV1>(method: .get, path: "/api/v1/instance") return Request<Instance>(method: .get, path: "/api/v1/instance")
}
public static func getInstanceV2() -> Request<InstanceV2> {
return Request<InstanceV2>(method: .get, path: "/api/v2/instance")
} }
public static func getCustomEmoji() -> Request<[Emoji]> { public static func getCustomEmoji() -> Request<[Emoji]> {
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
} }
public static func getPreferences() -> Request<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// MARK: - Accounts // MARK: - Accounts
public static func getAccount(id: String) -> Request<Account> { public static func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)") return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
@ -420,7 +395,7 @@ public class Client {
mediaIDs: [String]? = nil, mediaIDs: [String]? = nil,
sensitive: Bool? = nil, sensitive: Bool? = nil,
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: String? = nil, visibility: Visibility? = nil,
language: String? = nil, // language supported by mastodon and akkoma language: String? = nil, // language supported by mastodon and akkoma
pollOptions: [String]? = nil, pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil, pollExpiresIn: Int? = nil,
@ -433,7 +408,7 @@ public class Client {
"in_reply_to_id" => inReplyTo, "in_reply_to_id" => inReplyTo,
"sensitive" => sensitive, "sensitive" => sensitive,
"spoiler_text" => spoilerText, "spoiler_text" => spoilerText,
"visibility" => visibility, "visibility" => visibility?.rawValue,
"language" => language, "language" => language,
"poll[expires_in]" => pollExpiresIn, "poll[expires_in]" => pollExpiresIn,
"poll[multiple]" => pollMultiple, "poll[multiple]" => pollMultiple,

View File

@ -1,5 +1,5 @@
// //
// InstanceV1.swift // Instance.swift
// Pachyderm // Pachyderm
// //
// Created by Shadowfacts on 9/9/18. // Created by Shadowfacts on 9/9/18.
@ -8,7 +8,7 @@
import Foundation import Foundation
public struct InstanceV1: Decodable, Sendable { public struct Instance: Decodable, Sendable {
public let uri: String public let uri: String
public let title: String public let title: String
public let description: String public let description: String
@ -92,7 +92,7 @@ public struct InstanceV1: Decodable, Sendable {
} }
} }
extension InstanceV1 { extension Instance {
public struct Stats: Decodable, Sendable { public struct Stats: Decodable, Sendable {
public let domainCount: Int? public let domainCount: Int?
public let statusCount: Int? public let statusCount: Int?
@ -106,7 +106,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
public struct Configuration: Codable, Sendable { public struct Configuration: Codable, Sendable {
public let statuses: StatusesConfiguration public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration public let mediaAttachments: MediaAttachmentsConfiguration
@ -121,8 +121,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// note: also used by InstanceV2
public struct StatusesConfiguration: Codable, Sendable { public struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int public let maxCharacters: Int
public let maxMediaAttachments: Int public let maxMediaAttachments: Int
@ -136,8 +135,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// note: also used by InstanceV2
public struct MediaAttachmentsConfiguration: Codable, Sendable { public struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String] public let supportedMIMETypes: [String]
public let imageSizeLimit: Int public let imageSizeLimit: Int
@ -157,8 +155,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// note: also used by InstanceV2
public struct PollsConfiguration: Codable, Sendable { public struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int public let maxOptions: Int
public let maxCharactersPerOption: Int public let maxCharactersPerOption: Int
@ -174,8 +171,7 @@ extension InstanceV1 {
} }
} }
extension InstanceV1 { extension Instance {
// note: also used by InstanceV2
public struct Rule: Decodable, Identifiable, Sendable { public struct Rule: Decodable, Identifiable, Sendable {
public let id: String public let id: String
public let text: String public let text: String

View File

@ -1,125 +0,0 @@
//
// InstanceV2.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct InstanceV2: Decodable, Sendable {
public let domain: String
public let title: String
public let version: String
public let sourceURL: String
public let description: String
public let usage: Usage
public let thumbnail: Thumbnail
public let languages: [String]
public let configuration: Configuration
public let registrations: Registrations
public let contact: Contact
public let rules: [InstanceV1.Rule]
private enum CodingKeys: String, CodingKey {
case domain
case title
case version
case sourceURL = "source_url"
case description
case usage
case thumbnail
case languages
case configuration
case registrations
case contact
case rules
}
}
extension InstanceV2 {
public struct Usage: Decodable, Sendable {
public let users: Users
}
public struct Users: Decodable, Sendable {
public let activeMonth: Int
private enum CodingKeys: String, CodingKey {
case activeMonth = "active_month"
}
}
}
extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable {
public let url: String
public let blurhash: String?
public let versions: ThumbnailVersions
}
public struct ThumbnailVersions: Decodable, Sendable {
public let oneX: String?
public let twoX: String?
private enum CodingKeys: String, CodingKey {
case oneX = "@1x"
case twoX = "@2x"
}
}
}
extension InstanceV2 {
public struct Configuration: Decodable, Sendable {
public let urls: URLs
public let accounts: Accounts
public let statuses: InstanceV1.StatusesConfiguration
public let mediaAttachments: InstanceV1.MediaAttachmentsConfiguration
public let polls: InstanceV1.PollsConfiguration
public let translation: Translation
private enum CodingKeys: String, CodingKey {
case urls
case accounts
case statuses
case mediaAttachments = "media_attachments"
case polls
case translation
}
}
public struct URLs: Decodable, Sendable {
// the docs incorrectly say the key for this is "streaming_api"
public let streaming: String
}
public struct Accounts: Decodable, Sendable {
public let maxFeaturedTags: Int
private enum CodingKeys: String, CodingKey {
case maxFeaturedTags = "max_featured_tags"
}
}
public struct Translation: Decodable, Sendable {
public let enabled: Bool
}
}
extension InstanceV2 {
public struct Registrations: Decodable, Sendable {
public let enabled: Bool
public let approvalRequired: Bool
public let message: String?
private enum CodingKeys: String, CodingKey {
case enabled
case approvalRequired = "approval_required"
case message
}
}
}
extension InstanceV2 {
public struct Contact: Decodable, Sendable {
public let email: String
public let account: Account
}
}

View File

@ -11,18 +11,14 @@ import Foundation
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
public let id: String public let id: String
public let title: String public let title: String
public let replyPolicy: ReplyPolicy?
public let exclusive: Bool?
public var timeline: Timeline { public var timeline: Timeline {
return .list(id: id) return .list(id: id)
} }
public init(id: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) { public init(id: String, title: String) {
self.id = id self.id = id
self.title = title self.title = title
self.replyPolicy = replyPolicy
self.exclusive = exclusive
} }
public static func ==(lhs: List, rhs: List) -> Bool { public static func ==(lhs: List, rhs: List) -> Bool {
@ -40,15 +36,8 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
return request return request
} }
public static func update(_ listID: String, title: String, replyPolicy: ReplyPolicy?, exclusive: Bool?) -> Request<List> { public static func update(_ listID: String, title: String) -> Request<List> {
var params = ["title" => title] return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title]))
if let replyPolicy {
params.append("replies_policy" => replyPolicy.rawValue)
}
if let exclusive {
params.append("exclusive" => exclusive)
}
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(params))
} }
public static func delete(_ listID: String) -> Request<Empty> { public static func delete(_ listID: String) -> Request<Empty> {
@ -70,13 +59,5 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case title case title
case replyPolicy = "replies_policy"
case exclusive
}
}
extension List {
public enum ReplyPolicy: String, Codable, Hashable, CaseIterable, Sendable {
case followed, list, none
} }
} }

View File

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

View File

@ -1,37 +0,0 @@
//
// Preferences.swift
// Pachyderm
//
// Created by Shadowfacts on 10/26/23.
//
import Foundation
public struct Preferences: Codable, Sendable {
public let postingDefaultVisibility: Visibility
public let postingDefaultSensitive: Bool
public let postingDefaultLanguage: String
// Whether posts federate or not (local-only) on Hometown
public let postingDefaultFederation: Bool?
public let readingExpandMedia: ExpandMedia
public let readingExpandSpoilers: Bool
public let readingAutoplayGifs: Bool
enum CodingKeys: String, CodingKey {
case postingDefaultVisibility = "posting:default:visibility"
case postingDefaultSensitive = "posting:default:sensitive"
case postingDefaultLanguage = "posting:default:language"
case postingDefaultFederation = "posting:default:federation"
case readingExpandMedia = "reading:expand:media"
case readingExpandSpoilers = "reading:expand:spoilers"
case readingAutoplayGifs = "reading:autoplay:gifs"
}
}
extension Preferences {
public enum ExpandMedia: String, Codable, Sendable {
case `default`
case always = "show_all"
case never = "hide_all"
}
}

View File

@ -10,6 +10,4 @@ import Foundation
public protocol ListProtocol { public protocol ListProtocol {
var id: String { get } var id: String { get }
var title: String { get } var title: String { get }
var replyPolicy: List.ReplyPolicy? { get }
var exclusive: Bool? { get }
} }

View File

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

View File

@ -10,9 +10,6 @@ import Foundation
import WebURL import WebURL
public final class Status: StatusProtocol, Decodable, Sendable { public final class Status: StatusProtocol, Decodable, Sendable {
/// The pseudo-visibility used by instance types (Akkoma) that overload the visibility for local-only posts.
public static let localPostVisibility: String = "local"
public let id: String public let id: String
public let uri: String public let uri: String
public let url: WebURL? public let url: WebURL?
@ -66,8 +63,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID) self.inReplyToID = try container.decodeIfPresent(String.self, forKey: .inReplyToID)
self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID) self.inReplyToAccountID = try container.decodeIfPresent(String.self, forKey: .inReplyToAccountID)
self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog) self.reblog = try container.decodeIfPresent(Status.self, forKey: .reblog)
// pixelfed statuses may have null content self.content = try container.decode(String.self, forKey: .content)
self.content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? [] self.emojis = try container.decodeIfPresent([Emoji].self, forKey: .emojis) ?? []
self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount) self.reblogsCount = try container.decode(Int.self, forKey: .reblogsCount)
@ -81,7 +77,7 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.visibility = visibility self.visibility = visibility
self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) self.localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly)
} else if let s = try? container.decode(String.self, forKey: .visibility), } else if let s = try? container.decode(String.self, forKey: .visibility),
s == Status.localPostVisibility { s == "local" {
// hacky workaround for #332, akkoma describes local posts with a separate visibility // hacky workaround for #332, akkoma describes local posts with a separate visibility
self.visibility = .public self.visibility = .public
self.localOnly = true self.localOnly = true
@ -177,10 +173,6 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history") return Request(method: .get, path: "/api/v1/statuses/\(statusID)/history")
} }
public static func translate(_ statusID: String) -> Request<Translation> {
return Request(method: .post, path: "/api/v1/statuses/\(statusID)/translate")
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id
case uri case uri

View File

@ -1,22 +0,0 @@
//
// Translation.swift
// Pachyderm
//
// Created by Shadowfacts on 12/4/23.
//
import Foundation
public struct Translation: Decodable, Sendable {
public let content: String
public let spoilerText: String?
public let detectedSourceLanguage: String
public let provider: String
private enum CodingKeys: String, CodingKey {
case content
case spoilerText
case detectedSourceLanguage = "detected_source_language"
case provider
}
}

View File

@ -26,26 +26,13 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
} }
public func makeUIView(context: Context) -> UIButton { public func makeUIView(context: Context) -> UIButton {
let button = UIButton(configuration: makeConfiguration()) let button = UIButton()
button.showsMenuAsPrimaryAction = true button.showsMenuAsPrimaryAction = true
button.setContentHuggingPriority(.required, for: .horizontal) button.setContentHuggingPriority(.required, for: .horizontal)
return button return button
} }
public func updateUIView(_ button: UIButton, context: Context) { public func updateUIView(_ button: UIButton, context: Context) {
button.configuration = makeConfiguration()
button.menu = UIMenu(children: options.map { opt in
let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
}
private func makeConfiguration() -> UIButton.Configuration {
var config = UIButton.Configuration.borderless() var config = UIButton.Configuration.borderless()
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
config.indicator = .popup config.indicator = .popup
@ -56,10 +43,16 @@ public struct MenuPicker<Value: Hashable>: UIViewRepresentable {
if buttonStyle.hasLabel { if buttonStyle.hasLabel {
config.title = selectedOption.title config.title = selectedOption.title
} }
#if targetEnvironment(macCatalyst) button.configuration = config
config.macIdiomStyle = .bordered button.menu = UIMenu(children: options.map { opt in
#endif let action = UIAction(title: opt.title, subtitle: opt.subtitle, image: opt.image, state: opt.value == selection ? .on : .off) { _ in
return config selection = opt.value
}
action.accessibilityLabel = opt.accessibilityLabel
return action
})
button.accessibilityLabel = selectedOption.accessibilityLabel ?? selectedOption.title
button.isPointerInteractionEnabled = buttonStyle == .iconOnly
} }
public struct Option { public struct Option {

View File

@ -1,97 +0,0 @@
//
// PostVisibility.swift
// TuskerPreferences
//
// Created by Shadowfacts on 10/26/23.
//
import Foundation
import Pachyderm
public enum PostVisibility: Codable, Hashable, CaseIterable {
case serverDefault
case visibility(Visibility)
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self {
case .serverDefault:
// If the server doesn't have a default visibility preference, we fallback to public.
// This isn't ideal, but I don't want to add a separate preference for "Default Post Visibility Fallback" :/
serverDefault ?? .public
case .visibility(let vis):
vis
}
}
public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility {
switch self {
case .serverDefault:
await serverDefault() ?? .public
case .visibility(let vis):
vis
}
}
public var displayName: String {
switch self {
case .serverDefault:
return "Account Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .serverDefault:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}
public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
case visibility(Visibility)
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self {
case .sameAsPost:
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault)
case .visibility(let vis):
vis
}
}
public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility {
switch self {
case .sameAsPost:
await Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault)
case .visibility(let vis):
vis
}
}
public var displayName: String {
switch self {
case .sameAsPost:
return "Same as Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .sameAsPost:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}

View File

@ -61,14 +61,8 @@ public final class Preferences: Codable, ObservableObject {
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility)
self.defaultPostVisibility = .visibility(existing)
} else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
}
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
@ -127,8 +121,6 @@ public final class Preferences: Codable, ObservableObject {
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
@ -183,11 +175,9 @@ public final class Preferences: Codable, ObservableObject {
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing // MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultPostVisibility = Visibility.public
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false @Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@ -255,8 +245,6 @@ public final class Preferences: Codable, ObservableObject {
case leadingStatusSwipeActions case leadingStatusSwipeActions
case trailingStatusSwipeActions case trailingStatusSwipeActions
case widescreenNavigationMode case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility case defaultPostVisibility
case defaultReplyVisibility case defaultReplyVisibility
@ -300,6 +288,42 @@ public final class Preferences: Codable, ObservableObject {
} }
extension Preferences {
public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost
case visibility(Visibility)
public static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
public var resolved: Visibility {
switch self {
case .sameAsPost:
return Preferences.shared.defaultPostVisibility
case .visibility(let vis):
return vis
}
}
public var displayName: String {
switch self {
case .sameAsPost:
return "Same as Default"
case .visibility(let vis):
return vis.displayName
}
}
public var imageName: String? {
switch self {
case .sameAsPost:
return nil
case .visibility(let vis):
return vis.imageName
}
}
}
}
extension Preferences { extension Preferences {
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting case useStatusSetting
@ -412,6 +436,7 @@ extension Preferences {
public enum FeatureFlag: String, Codable { public enum FeatureFlag: String, Codable {
case iPadMultiColumn = "ipad-multi-column" case iPadMultiColumn = "ipad-multi-column"
case iPadBrowserNavigation = "ipad-browser-navigation" case iPadBrowserNavigation = "ipad-browser-navigation"
case iPadNavigationMode = "ipad-navigation-mode"
} }
} }

View File

@ -16,11 +16,6 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
public private(set) var username: String! public private(set) var username: String!
public let accessToken: String public let accessToken: String
// Sort of hack to be able to access these from the share extension.
public internal(set) var serverDefaultLanguage: String?
public internal(set) var serverDefaultVisibility: String?
public internal(set) var serverDefaultFederation: Bool?
fileprivate static let tempAccountID = "temp" fileprivate static let tempAccountID = "temp"
static func id(instanceURL: URL, username: String?) -> String { static func id(instanceURL: URL, username: String?) -> String {
@ -52,47 +47,21 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable {
self.accessToken = accessToken self.accessToken = accessToken
} }
init?(userDefaultsDict dict: [String: Any]) { init?(userDefaultsDict dict: [String: String]) {
guard let id = dict["id"] as? String, guard let id = dict["id"],
let instanceURL = dict["instanceURL"] as? String, let instanceURL = dict["instanceURL"],
let url = URL(string: instanceURL), let url = URL(string: instanceURL),
let clientID = dict["clientID"] as? String, let clientID = dict["clientID"],
let secret = dict["clientSecret"] as? String, let secret = dict["clientSecret"],
let accessToken = dict["accessToken"] as? String else { let accessToken = dict["accessToken"] else {
return nil return nil
} }
self.id = id self.id = id
self.instanceURL = url self.instanceURL = url
self.clientID = clientID self.clientID = clientID
self.clientSecret = secret self.clientSecret = secret
self.username = dict["username"] as? String self.username = dict["username"]
self.accessToken = accessToken self.accessToken = accessToken
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
}
var userDefaultsDict: [String: Any] {
var dict: [String: Any] = [
"id": id,
"instanceURL": instanceURL.absoluteString,
"clientID": clientID,
"clientSecret": clientSecret,
"accessToken": accessToken,
]
if let username {
dict["username"] = username
}
if let serverDefaultLanguage {
dict["serverDefaultLanguage"] = serverDefaultLanguage
}
if let serverDefaultVisibility {
dict["serverDefaultVisibility"] = serverDefaultVisibility
}
if let serverDefaultFederation {
dict["serverDefaultFederation"] = serverDefaultFederation
}
return dict
} }
/// A filename-safe string for this account /// A filename-safe string for this account

View File

@ -46,7 +46,19 @@ public class UserAccountsManager: ObservableObject {
} }
set { set {
objectWillChange.send() objectWillChange.send()
let array = newValue.map(\.userDefaultsDict) let array = newValue.map { (info) -> [String: String] in
var res = [
"id": info.id,
"instanceURL": info.instanceURL.absoluteString,
"clientID": info.clientID,
"clientSecret": info.clientSecret,
"accessToken": info.accessToken
]
if let username = info.username {
res["username"] = username
}
return res
}
defaults.set(array, forKey: accountsKey) defaults.set(array, forKey: accountsKey)
} }
} }
@ -134,17 +146,6 @@ public class UserAccountsManager: ObservableObject {
public func setMostRecentAccount(_ account: UserAccountInfo?) { public func setMostRecentAccount(_ account: UserAccountInfo?) {
mostRecentAccountID = account?.id mostRecentAccountID = account?.id
} }
public func updateServerPreferences(_ account: UserAccountInfo, defaultLanguage: String?, defaultVisibility: String?, defaultFederation: Bool?) {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
return
}
var account = account
account.serverDefaultLanguage = defaultLanguage
account.serverDefaultVisibility = defaultVisibility
account.serverDefaultFederation = defaultFederation
accounts[index] = account
}
} }

View File

@ -28,7 +28,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
self.instanceFeatures = InstanceFeatures() self.instanceFeatures = InstanceFeatures()
Task { @MainActor in Task { @MainActor in
async let instance = try? await run(Client.getInstanceV1()).0 async let instance = try? await run(Client.getInstance()).0
async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in async let nodeInfo: NodeInfo? = await withCheckedContinuation({ continuation in
self.client.nodeInfo { response in self.client.nodeInfo { response in
switch response { switch response {
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
} }
}) })
guard let instance = await instance else { return } guard let instance = await instance else { return }
self.instanceFeatures.update(instance: InstanceInfo(v1: instance), nodeInfo: await nodeInfo) self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo)
} }
Task { @MainActor in Task { @MainActor in

View File

@ -12,7 +12,6 @@ import ComposeUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import TuskerPreferences import TuskerPreferences
import Combine import Combine
import Pachyderm
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
@ -51,22 +50,21 @@ class ShareViewController: UIViewController {
} }
private func createDraft(account: UserAccountInfo) async -> Draft { private func createDraft(account: UserAccountInfo) async -> Draft {
async let (text, attachments) = getDraftConfigurationFromExtensionContext() let (text, attachments) = await getDraftConfigurationFromExtensionContext()
let draft = DraftsPersistentContainer.shared.createDraft( let draft = DraftsPersistentContainer.shared.createDraft(
accountID: account.id, accountID: account.id,
text: await text, text: text,
contentWarning: "", contentWarning: "",
inReplyToID: nil, inReplyToID: nil,
visibility: Preferences.shared.defaultPostVisibility.resolved(withServerDefault: account.serverDefaultVisibility.flatMap(Visibility.init(rawValue:))), visibility: Preferences.shared.defaultPostVisibility,
language: account.serverDefaultLanguage, localOnly: false
localOnly: !(account.serverDefaultFederation ?? true)
) )
for attachment in await attachments { for attachment in attachments {
DraftsPersistentContainer.shared.viewContext.insert(attachment) DraftsPersistentContainer.shared.viewContext.insert(attachment)
} }
draft.draftAttachments = await attachments draft.draftAttachments = attachments
return draft return draft
} }

View File

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

View File

@ -214,6 +214,8 @@
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; }; D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; }; D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; }; D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */; };
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */; };
D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; }; D6A4531629EF64BA00032932 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A4531529EF64BA00032932 /* ShareViewController.swift */; };
D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; }; D6A4531929EF64BA00032932 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6A4531729EF64BA00032932 /* MainInterface.storyboard */; };
D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D6A4531D29EF64BA00032932 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D6A4531329EF64BA00032932 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@ -256,6 +258,7 @@
D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; }; D6B9366D2828445000237D0E /* SavedInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366C2828444F00237D0E /* SavedInstance.swift */; };
D6B9366F2828452F00237D0E /* SavedHashtag.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9366E2828452F00237D0E /* SavedHashtag.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 */; }; D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.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 */; };
D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; }; D6BC9DD7232D7811002CA326 /* TimelinesPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */; };
@ -264,7 +267,6 @@
D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; }; D6BD395B29B64441005FFD2B /* ComposeHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BD395A29B64441005FFD2B /* ComposeHostingController.swift */; };
D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; }; D6BEA245291A0EDE002F4D01 /* Duckable in Frameworks */ = {isa = PBXBuildFile; productRef = D6BEA244291A0EDE002F4D01 /* Duckable */; };
D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; }; D6BEA247291A0F2D002F4D01 /* Duckable+Root.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */; };
D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C041C32AED77730094D32D /* EditListSettingsService.swift */; };
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
@ -284,9 +286,6 @@
D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6CA6ED129EF6091003EC5DF /* TuskerPreferences */; }; D6CA6ED229EF6091003EC5DF /* TuskerPreferences in Frameworks */ = {isa = PBXBuildFile; productRef = D6CA6ED129EF6091003EC5DF /* TuskerPreferences */; };
D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; }; D6CA8CDA2962231F0050C433 /* ArrayUniqueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */; };
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; }; D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; };
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */; };
D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */; };
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */; };
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; };
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; };
@ -338,7 +337,6 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; }; D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; };
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; }; D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; };
@ -616,6 +614,8 @@
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; }; D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; }; D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; }; D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountTableViewCell.swift; sourceTree = "<group>"; };
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AccountTableViewCell.xib; sourceTree = "<group>"; };
D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; D6A4531329EF64BA00032932 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; D6A4531529EF64BA00032932 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; }; D6A4531829EF64BA00032932 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@ -648,7 +648,6 @@
D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; }; D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedPageViewController.swift; sourceTree = "<group>"; };
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; }; D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = "<group>"; };
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; }; D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameView.swift; sourceTree = "<group>"; };
D6B5F3BC2ACA586C00309734 /* Version.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Version.xcconfig; sourceTree = "<group>"; };
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; }; 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>"; };
@ -657,6 +656,7 @@
D6B9366C2828444F00237D0E /* SavedInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedInstance.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>"; }; 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>"; }; 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>"; };
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>"; };
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; }; D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinesPageViewController.swift; sourceTree = "<group>"; };
@ -666,7 +666,6 @@
D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; }; D6BD395C29B789D5005FFD2B /* TuskerComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerComponents; path = Packages/TuskerComponents; sourceTree = "<group>"; };
D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; }; D6BEA243291A0C83002F4D01 /* Duckable */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Duckable; path = Packages/Duckable; sourceTree = "<group>"; };
D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; }; D6BEA246291A0F2D002F4D01 /* Duckable+Root.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Duckable+Root.swift"; sourceTree = "<group>"; };
D6C041C32AED77730094D32D /* EditListSettingsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSettingsService.swift; sourceTree = "<group>"; };
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
@ -686,9 +685,6 @@
D6CA6ED029EF6060003EC5DF /* TuskerPreferences */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerPreferences; path = Packages/TuskerPreferences; sourceTree = "<group>"; }; D6CA6ED029EF6060003EC5DF /* TuskerPreferences */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TuskerPreferences; path = Packages/TuskerPreferences; sourceTree = "<group>"; };
D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; }; D6CA8CD92962231F0050C433 /* ArrayUniqueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArrayUniqueTests.swift; sourceTree = "<group>"; };
D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; }; D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = "<group>"; };
D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSCollectionLayoutSection+Readable.swift"; sourceTree = "<group>"; };
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiColumnCollectionViewLayout.swift; sourceTree = "<group>"; };
D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonSearchController.swift; sourceTree = "<group>"; };
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = "<group>"; };
D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = "<group>"; };
@ -750,7 +746,6 @@
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; }; D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; }; D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; }; D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = "<group>"; };
D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; }; D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = "<group>"; };
@ -1250,7 +1245,6 @@
D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */, D61F75AC293AF39000C0B37F /* Filter+Helpers.swift */,
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */, D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */,
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */, D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */,
D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1292,6 +1286,8 @@
D6A3BC872321F78000FD64D5 /* Account Cell */ = { D6A3BC872321F78000FD64D5 /* Account Cell */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6A3BC882321F79B00FD64D5 /* AccountTableViewCell.swift */,
D6A3BC892321F79B00FD64D5 /* AccountTableViewCell.xib */,
D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */, D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */,
); );
path = "Account Cell"; path = "Account Cell";
@ -1363,8 +1359,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */, D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */,
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */,
D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */,
); );
path = Search; path = Search;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1421,6 +1415,7 @@
D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */, D693DE5623FE1A6A0061E07D /* EnhancedNavigationViewController.swift */,
D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */, D6E77D0E286F773900D8B732 /* SplitNavigationController.swift */,
D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */, D62D67C42A97D8CD00167EE2 /* MultiColumnNavigationController.swift */,
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */,
D693DE5823FE24300061E07D /* InteractivePushTransition.swift */, D693DE5823FE24300061E07D /* InteractivePushTransition.swift */,
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */, D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */,
D6E0DC8D216EDF1E00369478 /* Previewing.swift */, D6E0DC8D216EDF1E00369478 /* Previewing.swift */,
@ -1454,7 +1449,6 @@
children = ( children = (
D63CC703290EC472000E19DE /* Dist.xcconfig */, D63CC703290EC472000E19DE /* Dist.xcconfig */,
D6D706A829498C82000827ED /* Tusker.xcconfig */, D6D706A829498C82000827ED /* Tusker.xcconfig */,
D6B5F3BC2ACA586C00309734 /* Version.xcconfig */,
D674A50727F910F300BA03AC /* Pachyderm */, D674A50727F910F300BA03AC /* Pachyderm */,
D6BEA243291A0C83002F4D01 /* Duckable */, D6BEA243291A0C83002F4D01 /* Duckable */,
D68A76F22953915C001DA1B3 /* TTTKit */, D68A76F22953915C001DA1B3 /* TTTKit */,
@ -1505,7 +1499,6 @@
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */, D61DC84528F498F200B82C6E /* Logging.swift */,
D6B81F432560390300F6E31D /* MenuController.swift */, D6B81F432560390300F6E31D /* MenuController.swift */,
D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */,
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */, D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */,
D6945C2E23AC47C3005C403C /* SavedDataManager.swift */, D6945C2E23AC47C3005C403C /* SavedDataManager.swift */,
D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */, D61F759E29385AD800C0B37F /* SemiCaseSensitiveComparator.swift */,
@ -1638,7 +1631,6 @@
D621733228F1D5ED004C7DB1 /* ReblogService.swift */, D621733228F1D5ED004C7DB1 /* ReblogService.swift */,
D6F6A54F291F058600F496A8 /* CreateListService.swift */, D6F6A54F291F058600F496A8 /* CreateListService.swift */,
D6F6A551291F098700F496A8 /* RenameListService.swift */, D6F6A551291F098700F496A8 /* RenameListService.swift */,
D6C041C32AED77730094D32D /* EditListSettingsService.swift */,
D6F6A553291F0D9600F496A8 /* DeleteListService.swift */, D6F6A553291F0D9600F496A8 /* DeleteListService.swift */,
D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */, D61F75952937037800C0B37F /* ToggleFollowHashtagService.swift */,
D61F75B0293BD85300C0B37F /* CreateFilterService.swift */, D61F75B0293BD85300C0B37F /* CreateFilterService.swift */,
@ -1778,9 +1770,8 @@
D6D4DDC4212518A000E1C4BB /* Project object */ = { D6D4DDC4212518A000E1C4BB /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1430; LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1500; LastUpgradeCheck = 1400;
ORGANIZATIONNAME = Shadowfacts; ORGANIZATIONNAME = Shadowfacts;
TargetAttributes = { TargetAttributes = {
D6A4531229EF64BA00032932 = { D6A4531229EF64BA00032932 = {
@ -1855,6 +1846,7 @@
D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */, D6E77D0D286E6B7300D8B732 /* TrendingLinkCardCollectionViewCell.xib in Resources */,
D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */, D693A73025CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.xib in Resources */,
D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */, D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */,
D6A3BC8B2321F79B00FD64D5 /* AccountTableViewCell.xib in Resources */,
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */, D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */,
D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */, D6D4DDDA212518A200E1C4BB /* LaunchScreen.storyboard in Resources */,
D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */, D6E57FA325C26FAB00341037 /* Localizable.stringsdict in Resources */,
@ -1980,6 +1972,7 @@
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */, D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */, D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */, D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */, D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */, D65B4B682977769E00DABDFB /* StatusActionAccountListViewController.swift in Sources */,
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
@ -2084,7 +2077,6 @@
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */, D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */,
D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */, D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */,
D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */, D6B936712829F72900237D0E /* NSManagedObjectContext+Helpers.swift in Sources */,
D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */,
D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */, D68A76EE295369C7001DA1B3 /* AcknowledgementsView.swift in Sources */,
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */, D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */,
D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */, D63CC7102911F1E4000E19DE /* UIScrollView+Top.swift in Sources */,
@ -2130,7 +2122,6 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D620483423D3801D008A63EF /* LinkTextView.swift in Sources */,
D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */,
D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */,
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */,
D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */, D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */,
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */,
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */,
@ -2143,7 +2134,6 @@
D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */, D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */,
D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */, D6333B372137838300CE884A /* AttributedString+Helpers.swift in Sources */,
D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */, D681A29A249AD62D0085E54E /* LargeImageContentView.swift in Sources */,
D6C041C42AED77730094D32D /* EditListSettingsService.swift in Sources */,
D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */, D6DFC69E242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */, D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */,
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */, D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */,
@ -2214,12 +2204,12 @@
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,
D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */,
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */, D667E5F12134D5050057A976 /* UIViewController+Delegates.swift in Sources */,
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */,
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */, D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */, D6E426B325337C7000C02E1C /* CustomEmojiImageView.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
@ -2227,7 +2217,6 @@
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */, D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */,
D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */, D65B4B6A297777D900DABDFB /* StatusNotFoundView.swift in Sources */,
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */, D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */,
D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */,
D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */, D601FA5D297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift in Sources */,
D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */, D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */,
D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */, D60088F22980DAA0005B4D00 /* TipJarView.swift in Sources */,
@ -2409,7 +2398,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 103;
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;
@ -2417,7 +2406,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 2023.7;
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -2475,7 +2464,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 100;
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;
@ -2500,7 +2489,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 100;
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 +2517,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 100;
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;
@ -2556,7 +2545,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 100;
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;
@ -2710,7 +2699,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 103;
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;
@ -2718,7 +2707,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 2023.7;
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = ""; OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
@ -2741,7 +2730,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 103;
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;
@ -2749,7 +2738,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 2023.7;
OTHER_CODE_SIGN_FLAGS = ""; OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -2847,7 +2836,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 100;
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;
@ -2872,7 +2861,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 = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = 100;
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;
@ -2969,7 +2958,7 @@
repositoryURL = "https://github.com/getsentry/sentry-cocoa.git"; repositoryURL = "https://github.com/getsentry/sentry-cocoa.git";
requirement = { requirement = {
kind = upToNextMinorVersion; kind = upToNextMinorVersion;
minimumVersion = 8.15.0; minimumVersion = 8.0.0;
}; };
}; };
D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = { D6552365289870790048A653 /* XCRemoteSwiftPackageReference "ScreenCorners" */ = {

View File

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

View File

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

View File

@ -35,7 +35,8 @@ class DeleteStatusService {
reblogIDs = reblogs.map(\.id) reblogIDs = reblogs.map(\.id)
} }
NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [ NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
"accountID": mastodonController.accountInfo!.id,
"statusIDs": [status.id] + reblogIDs, "statusIDs": [status.id] + reblogIDs,
]) ])
} catch { } catch {

View File

@ -1,46 +0,0 @@
//
// EditListSettingsService.swift
// Tusker
//
// Created by Shadowfacts on 10/28/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
@MainActor
class EditListSettingsService {
private let list: ListProtocol
private let mastodonController: MastodonController
private let present: (UIViewController) -> Void
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list
self.mastodonController = mastodonController
self.present = present
}
func run(title: String? = nil, replyPolicy: List.ReplyPolicy? = nil, exclusive: Bool? = nil) async {
do {
let req = List.update(
list.id,
title: title ?? list.title,
replyPolicy: replyPolicy ?? list.replyPolicy,
exclusive: exclusive ?? list.exclusive
)
let (list, _) = try await mastodonController.run(req)
mastodonController.updatedList(list)
} catch {
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Retry", style: .default, handler: { _ in
Task {
await self.run(title: title, replyPolicy: replyPolicy, exclusive: exclusive)
}
}))
present(alert)
}
}
}

View File

@ -36,6 +36,11 @@ class FetchStatusService {
} }
private func handleStatusNotFound() { private func handleStatusNotFound() {
// todo: what about when browsing on another instance?
guard let accountID = mastodonController.accountInfo?.id else {
return
}
var reblogIDs = [String]() var reblogIDs = [String]()
if let cached = mastodonController.persistentContainer.status(for: statusID) { if let cached = mastodonController.persistentContainer.status(for: statusID) {
let reblogsReq = StatusMO.fetchRequest() let reblogsReq = StatusMO.fetchRequest()
@ -45,7 +50,8 @@ class FetchStatusService {
} }
} }
NotificationCenter.default.post(name: .statusDeleted, object: mastodonController, userInfo: [ NotificationCenter.default.post(name: .statusDeleted, object: nil, userInfo: [
"accountID": accountID,
"statusIDs": [statusID] + reblogIDs "statusIDs": [statusID] + reblogIDs
]) ])
} }

View File

@ -53,8 +53,7 @@ class MastodonController: ObservableObject {
let instanceFeatures = InstanceFeatures() let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO? @Published private(set) var account: AccountMO?
@Published private(set) var instance: InstanceV1? @Published private(set) var instance: Instance?
@Published private var instanceV2: InstanceV2?
@Published private(set) var instanceInfo: InstanceInfo! @Published private(set) var instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo! @Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = [] @Published private(set) var lists: [List] = []
@ -64,7 +63,7 @@ class MastodonController: ObservableObject {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var pendingOwnInstanceRequestCallbacks = [(Result<InstanceV1, Client.Error>) -> Void]() private var pendingOwnInstanceRequestCallbacks = [(Result<Instance, Client.Error>) -> Void]()
private var ownInstanceRequest: URLSessionTask? private var ownInstanceRequest: URLSessionTask?
var loggedIn: Bool { var loggedIn: Bool {
@ -108,14 +107,9 @@ class MastodonController: ObservableObject {
$instance $instance
.compactMap { $0 } .compactMap { $0 }
.combineLatest($instanceV2) .sink { [unowned self] in
.sink {[unowned self] (instance, v2) in self.updateActiveInstance(from: $0)
var info = InstanceInfo(v1: instance) self.instanceInfo = InstanceInfo(instance: $0)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -199,8 +193,6 @@ class MastodonController: ObservableObject {
@MainActor @MainActor
func initialize() { func initialize() {
precondition(!transient, "Cannot initialize transient MastodonController")
// we want this to happen immediately, and synchronously so that the filters (which don't change that often) // we want this to happen immediately, and synchronously so that the filters (which don't change that often)
// are available when Filterers are constructed // are available when Filterers are constructed
loadCachedFilters() loadCachedFilters()
@ -223,13 +215,8 @@ class MastodonController: ObservableObject {
_ = try await (ownAccount, ownInstance) _ = try await (ownAccount, ownInstance)
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
async let _ = try? getOwnInstanceV2()
}
loadLists() loadLists()
_ = await loadFilters() _ = await loadFilters()
await loadServerPreferences()
} catch { } catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))") Logging.general.error("MastodonController initialization failed: \(String(describing: error))")
} }
@ -287,7 +274,7 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnInstance(completion: ((InstanceV1) -> Void)? = nil) { func getOwnInstance(completion: ((Instance) -> Void)? = nil) {
getOwnInstanceInternal(retryAttempt: 0) { getOwnInstanceInternal(retryAttempt: 0) {
if case let .success(instance) = $0 { if case let .success(instance) = $0 {
completion?(instance) completion?(instance)
@ -296,7 +283,7 @@ class MastodonController: ObservableObject {
} }
@MainActor @MainActor
func getOwnInstance() async throws -> InstanceV1 { func getOwnInstance() async throws -> Instance {
return try await withCheckedThrowingContinuation({ continuation in return try await withCheckedThrowingContinuation({ continuation in
getOwnInstanceInternal(retryAttempt: 0) { result in getOwnInstanceInternal(retryAttempt: 0) { result in
continuation.resume(with: result) continuation.resume(with: result)
@ -304,7 +291,7 @@ class MastodonController: ObservableObject {
}) })
} }
private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<InstanceV1, Client.Error>) -> Void)?) { private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result<Instance, Client.Error>) -> Void)?) {
// this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks
assert(Thread.isMainThread) assert(Thread.isMainThread)
@ -316,7 +303,7 @@ class MastodonController: ObservableObject {
} }
if ownInstanceRequest == nil { if ownInstanceRequest == nil {
let request = Client.getInstanceV1() let request = Client.getInstance()
ownInstanceRequest = run(request) { (response) in ownInstanceRequest = run(request) { (response) in
switch response { switch response {
case .failure(let error): case .failure(let error):
@ -371,32 +358,13 @@ class MastodonController: ObservableObject {
} }
} }
private func getOwnInstanceV2() async throws { private func updateActiveInstance(from instance: Instance) {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0
}
// MainActor because the accountPreferences instance is bound to the view context
@MainActor
private func loadServerPreferences() async {
guard instanceFeatures.hasServerPreferences,
let (prefs, _) = try? await run(Client.getPreferences()) else {
return
}
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
if let accountInfo {
UserAccountsManager.shared.updateServerPreferences(accountInfo, defaultLanguage: prefs.postingDefaultLanguage, defaultVisibility: prefs.postingDefaultVisibility.rawValue, defaultFederation: prefs.postingDefaultFederation)
}
}
private func updateActiveInstance(from info: InstanceInfo) {
persistentContainer.performBackgroundTask { context in persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: info) existing.update(from: instance)
} else { } else {
let new = ActiveInstance(context: context) let new = ActiveInstance(context: context)
new.update(from: info) new.update(from: instance)
} }
if context.hasChanges { if context.hasChanges {
try? context.save() try? context.save()
@ -466,12 +434,16 @@ class MastodonController: ObservableObject {
guard let lists = try? persistentContainer.viewContext.fetch(req) else { guard let lists = try? persistentContainer.viewContext.fetch(req) else {
return [] return []
} }
return lists.map(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) return lists.map {
List(id: $0.id, title: $0.title)
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
} }
func getCachedList(id: String) -> List? { func getCachedList(id: String) -> List? {
let req = ListMO.fetchRequest(id: id) let req = ListMO.fetchRequest(id: id)
return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList) return (try? persistentContainer.viewContext.fetch(req).first).flatMap {
List(id: $0.id, title: $0.title)
}
} }
@MainActor @MainActor
@ -488,7 +460,7 @@ class MastodonController: ObservableObject {
} }
@MainActor @MainActor
func updatedList(_ list: List) { func renamedList(_ list: List) {
var new = self.lists var new = self.lists
if let index = new.firstIndex(where: { $0.id == list.id }) { if let index = new.firstIndex(where: { $0.id == list.id }) {
new[index] = list new[index] = list
@ -547,12 +519,8 @@ class MastodonController: ObservableObject {
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
var acctsToMention = [String]() var acctsToMention = [String]()
var visibility = if inReplyToID != nil { var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility
Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) var localOnly = false
} else {
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
}
var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation
var contentWarning = "" var contentWarning = ""
if let inReplyToID = inReplyToID, if let inReplyToID = inReplyToID,
@ -591,7 +559,6 @@ class MastodonController: ObservableObject {
contentWarning: contentWarning, contentWarning: contentWarning,
inReplyToID: inReplyToID, inReplyToID: inReplyToID,
visibility: visibility, visibility: visibility,
language: accountPreferences!.serverDefaultLanguage,
localOnly: localOnly localOnly: localOnly
) )
} }

View File

@ -47,9 +47,9 @@ class RenameListService {
private func updateList(with title: String) async { private func updateList(with title: String) async {
do { do {
let req = List.update(list.id, title: title, replyPolicy: nil, exclusive: nil) let req = List.update(list.id, title: title)
let (list, _) = try await mastodonController.run(req) let (list, _) = try await mastodonController.run(req)
mastodonController.updatedList(list) mastodonController.renamedList(list)
} catch { } catch {
let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert) let alert = UIAlertController(title: "Error Updating List", message: error.localizedDescription, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))

View File

@ -162,6 +162,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
@objc func closeWindow() {
guard let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) else {
return
}
UIApplication.shared.requestSceneSessionDestruction(scene.session, options: nil)
}
private func swizzleStatusBar() { private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:")) let selector = Selector(("handleTapAction:"))
var originalIMP: IMP? var originalIMP: IMP?

View File

@ -59,14 +59,9 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
super.awakeFromFetch() super.awakeFromFetch()
managedObjectContext?.perform { managedObjectContext?.perform {
self.touch() self.lastFetchedAt = Date()
} }
} }
/// Update the `lastFetchedAt` date so this object isn't pruned early.
func touch() {
lastFetchedAt = Date()
}
} }

View File

@ -24,22 +24,10 @@ public final class AccountPreferences: NSManagedObject {
@NSManaged public var accountID: String @NSManaged public var accountID: String
@NSManaged var createdAt: Date @NSManaged var createdAt: Date
@NSManaged var pinnedTimelinesData: Data? @NSManaged var pinnedTimelinesData: Data?
@NSManaged var serverDefaultFederation: Bool
@NSManaged var serverDefaultLanguage: String?
@NSManaged private var serverDefaultVisibilityString: String?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines)
var pinnedTimelines: [PinnedTimeline] var pinnedTimelines: [PinnedTimeline]
var serverDefaultVisibility: Visibility? {
get {
serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:))
}
set {
serverDefaultVisibilityString = newValue?.rawValue
}
}
static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context) let prefs = AccountPreferences(context: context)
prefs.accountID = account.id prefs.accountID = account.id

View File

@ -22,20 +22,18 @@ public final class ActiveInstance: NSManagedObject {
@NSManaged public var maxStatusCharacters: Int @NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data? @NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data? @NSManaged private var pollsConfigurationData: Data?
@NSManaged public var translation: Bool
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: InstanceV1.Configuration? public var configuration: Instance.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil) @LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: InstanceV1.PollsConfiguration? public var pollsConfiguration: Instance.PollsConfiguration?
func update(from info: InstanceInfo) { func update(from instance: Instance) {
self.version = info.version self.version = instance.version
self.maxStatusCharacters = info.maxStatusCharacters ?? 500 self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
self.configuration = info.configuration self.configuration = instance.configuration
self.pollsConfiguration = info.pollsConfiguration self.pollsConfiguration = instance.pollsConfiguration
self.translation = info.translation
} }
} }
@ -45,8 +43,7 @@ extension InstanceInfo {
version: activeInstance.version, version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters, maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration, configuration: activeInstance.configuration,
pollsConfiguration: activeInstance.pollsConfiguration, pollsConfiguration: activeInstance.pollsConfiguration
translation: activeInstance.translation
) )
} }
} }

View File

@ -25,22 +25,6 @@ public final class ListMO: NSManagedObject, ListProtocol {
@NSManaged public var id: String @NSManaged public var id: String
@NSManaged public var title: String @NSManaged public var title: String
@NSManaged private var replyPolicyString: String?
@NSManaged private var exclusiveInternal: Bool
public var replyPolicy: List.ReplyPolicy? {
get {
replyPolicyString.flatMap(List.ReplyPolicy.init(rawValue:))
}
set {
replyPolicyString = newValue?.rawValue
}
}
public var exclusive: Bool? {
get { exclusiveInternal }
set { exclusiveInternal = newValue ?? false }
}
} }
@ -53,16 +37,5 @@ extension ListMO {
func updateFrom(apiList list: List) { func updateFrom(apiList list: List) {
self.id = list.id self.id = list.id
self.title = list.title self.title = list.title
self.replyPolicy = list.replyPolicy
self.exclusive = list.exclusive
}
var apiList: List {
List(
id: id,
title: title,
replyPolicy: replyPolicy,
exclusive: exclusive
)
} }
} }

View File

@ -54,7 +54,6 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
@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? @NSManaged public var lastFetchedAt: Date?
@NSManaged public var language: String?
@LazilyDecoding(arrayFrom: \StatusMO.attachmentsData) @LazilyDecoding(arrayFrom: \StatusMO.attachmentsData)
public var attachments: [Attachment] public var attachments: [Attachment]
@ -90,15 +89,10 @@ public final class StatusMO: NSManagedObject, StatusProtocol {
super.awakeFromFetch() super.awakeFromFetch()
managedObjectContext?.perform { managedObjectContext?.perform {
self.touch() self.lastFetchedAt = Date()
} }
} }
/// Update the `lastFetchedAt` date so this object isn't pruned early.
func touch() {
lastFetchedAt = Date()
}
} }
extension StatusMO { extension StatusMO {
@ -140,7 +134,6 @@ extension StatusMO {
self.visibility = status.visibility self.visibility = status.visibility
self.poll = status.poll self.poll = status.poll
self.localOnly = status.localOnly ?? false self.localOnly = status.localOnly ?? false
self.language = status.language
if let existing = container.account(for: status.account.id, in: context) { if let existing = container.account(for: status.account.id, in: context) {
existing.updateFrom(apiAccount: status.account, container: container) existing.updateFrom(apiAccount: status.account, container: container)

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="22522" systemVersion="23B92" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" 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="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -33,15 +33,11 @@
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/> <attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
<attribute name="serverDefaultFederation" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
<attribute name="serverDefaultLanguage" optional="YES" attributeType="String"/>
<attribute name="serverDefaultVisibilityString" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES"> <entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
<attribute name="configurationData" optional="YES" attributeType="Binary"/> <attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/> <attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="translation" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="version" optional="YES" attributeType="String"/> <attribute name="version" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES"> <entity name="Filter" representedClassName="FilterMO" syncable="YES">
@ -63,9 +59,7 @@
<attribute name="url" attributeType="URI"/> <attribute name="url" attributeType="URI"/>
</entity> </entity>
<entity name="List" representedClassName="ListMO" syncable="YES"> <entity name="List" representedClassName="ListMO" syncable="YES">
<attribute name="exclusiveInternal" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="id" optional="YES" attributeType="String"/> <attribute name="id" optional="YES" attributeType="String"/>
<attribute name="replyPolicyString" optional="YES" attributeType="String"/>
<attribute name="title" optional="YES" attributeType="String"/> <attribute name="title" optional="YES" attributeType="String"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
@ -110,7 +104,6 @@
<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="language" optional="YES" attributeType="String"/>
<attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="lastFetchedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="localOnly" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="mentionsData" attributeType="Binary"/> <attribute name="mentionsData" attributeType="Binary"/>
@ -125,8 +118,7 @@
<attribute name="url" optional="YES" attributeType="URI"/> <attribute name="url" optional="YES" attributeType="URI"/>
<attribute name="visibilityString" attributeType="String"/> <attribute name="visibilityString" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/> <relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Account" inverseName="statuses" inverseEntity="Account"/>
<relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status" inverseName="reblogs" inverseEntity="Status"/> <relationship name="reblog" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Status"/>
<relationship name="reblogs" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Status" inverseName="reblog" inverseEntity="Status"/>
<relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/> <relationship name="timelines" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="TimelineState" inverseName="statuses" inverseEntity="TimelineState"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>

View File

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

View File

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

View File

@ -7,13 +7,12 @@
// //
import Foundation import Foundation
import CoreData
private let decoder = PropertyListDecoder() private let decoder = PropertyListDecoder()
private let encoder = PropertyListEncoder() private let encoder = PropertyListEncoder()
@propertyWrapper @propertyWrapper
public struct LazilyDecoding<Enclosing: NSManagedObject, Value: Codable> { public struct LazilyDecoding<Enclosing: NSObject, Value: Codable> {
private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?> private let keyPath: ReferenceWritableKeyPath<Enclosing, Data?>
private let fallback: Value private let fallback: Value

View File

@ -41,25 +41,22 @@ 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.replace(menu: .file, with: buildFileMenu(builder: builder)) 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(builder: UIMenuBuilder) -> UIMenu { private static func buildFileMenu() -> UIMenu {
var children: [UIMenuElement] = [
composeCommand,
refreshCommand(discoverabilityTitle: nil),
]
if let close = builder.menu(for: .close) {
children.append(close)
}
return UIMenu( return UIMenu(
title: "File", title: "File",
image: nil, image: nil,
identifier: nil, identifier: nil,
options: [], options: [],
children: children children: [
composeCommand,
refreshCommand(discoverabilityTitle: nil),
UIKeyCommand(title: "Close", action: #selector(AppDelegate.closeWindow), input: "w", modifierFlags: .command),
]
) )
} }

View File

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

View File

@ -112,7 +112,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
case .list(id: let id): case .list(id: let id):
let req = ListMO.fetchRequest(id: id) let req = ListMO.fetchRequest(id: id)
if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first { if let list = try? mastodonController.persistentContainer.viewContext.fetch(req).first {
return ListTimelineViewController(for: list.apiList, mastodonController: mastodonController) return ListTimelineViewController(for: List(id: id, title: list.title), mastodonController: mastodonController)
} else { } else {
return TimelineViewController(for: timeline, mastodonController: mastodonController) return TimelineViewController(for: timeline, mastodonController: mastodonController)
} }

View File

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

View File

@ -35,7 +35,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
window = UIWindow(windowScene: windowScene) window = UIWindow(windowScene: windowScene)
showAppOrOnboardingUI(session: session) showAppOrOnboardingUI(session: session)
if !connectionOptions.urlContexts.isEmpty { if connectionOptions.urlContexts.count > 0 {
self.scene(scene, openURLContexts: connectionOptions.urlContexts) self.scene(scene, openURLContexts: connectionOptions.urlContexts)
} }
@ -50,21 +50,14 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} }
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url, if URLContexts.count > 1 {
var components = URLComponents(url: url, resolvingAgainstBaseURL: false), fatalError("Cannot open more than 1 URL")
let rootViewController else {
return
} }
if components.host == "compose" { let url = URLContexts.first!.url
if let mastodonController = window!.windowScene!.session.mastodonController {
let draft = mastodonController.createDraft() if var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let text = components.queryItems?.first(where: { $0.name == "text" })?.value let rootViewController = rootViewController {
draft.text = text ?? ""
rootViewController.compose(editing: draft, animated: true, isDucked: false)
}
} else {
// Assume anything else is a search query
components.scheme = "https" components.scheme = "https"
let query = components.string! let query = components.string!
rootViewController.performSearch(query: query) rootViewController.performSearch(query: query)
@ -148,7 +141,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
minDate.addTimeInterval(-7 * 24 * 60 * 60) minDate.addTimeInterval(-7 * 24 * 60 * 60)
let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest() let statusReq: NSFetchRequest<NSFetchRequestResult> = StatusMO.fetchRequest()
statusReq.predicate = NSPredicate(format: "((lastFetchedAt = nil) OR (lastFetchedAt < %@)) AND (reblogs.@count = 0)", minDate as NSDate) statusReq.predicate = NSPredicate(format: "(lastFetchedAt = nil) OR (lastFetchedAt < %@)", minDate as NSDate)
let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq) let deleteStatusReq = NSBatchDeleteRequest(fetchRequest: statusReq)
deleteStatusReq.resultType = .resultTypeCount deleteStatusReq.resultType = .resultTypeCount
if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult { if let res = try? context.execute(deleteStatusReq) as? NSBatchDeleteResult {

View File

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

View File

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

View File

@ -15,7 +15,6 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
private let mastodonController: MastodonController private let mastodonController: MastodonController
private let mainStatusID: String private let mainStatusID: String
private let mainStatusState: CollapseState private let mainStatusState: CollapseState
private var mainStatusTranslation: Translation?
var statusIDToScrollToOnLoad: String var statusIDToScrollToOnLoad: String
var showStatusesAutomatically = false var showStatusesAutomatically = false
@ -60,12 +59,10 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
return config return config
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in // we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) // the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the
section.readableContentInset(in: environment) // background color always peeking through the edges
return section let layout = UICollectionViewCompositionalLayout.list(using: config)
}
viewRespectsSystemMinimumLayoutMargins = false
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
// something about the autoresizing mask breaks resizing the vc // something about the autoresizing mask breaks resizing the vc
view.translatesAutoresizingMaskIntoConstraints = false view.translatesAutoresizingMaskIntoConstraints = false
@ -89,14 +86,11 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
cell.setShowThreadLinks(prev: item.2, next: item.3) cell.setShowThreadLinks(prev: item.2, next: item.3)
} }
let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Translation?, Bool)> { [unowned self] cell, indexPath, item in let mainStatusCell = UICollectionView.CellRegistration<ConversationMainStatusCollectionViewCell, (String, CollapseState, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self cell.delegate = self
cell.translateStatus = { [unowned self] in
self.translateMainStatus()
}
cell.showStatusAutomatically = self.showStatusesAutomatically cell.showStatusAutomatically = self.showStatusesAutomatically
cell.updateUI(statusID: item.0, state: item.1, translation: item.2) cell.updateUI(statusID: item.0, state: item.1)
cell.setShowThreadLinks(prev: item.3, next: false) cell.setShowThreadLinks(prev: item.2, next: false)
} }
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
cell.updateUI(childThreads: item.0, inline: item.1) cell.updateUI(childThreads: item.0, inline: item.1)
@ -108,7 +102,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
switch itemIdentifier { switch itemIdentifier {
case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID { if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, self.mainStatusTranslation, prevLink)) return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
} else { } else {
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink)) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink))
} }
@ -264,30 +258,6 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
} }
private func translateMainStatus() {
Task { @MainActor in
let translation: Translation
do {
translation = try await mastodonController.run(Status.translate(mainStatusID)).0
} catch {
let config = ToastConfiguration(from: error, with: "Error Translating", in: self) { toast in
toast.dismissToast(animated: true)
self.translateMainStatus()
}
self.showToast(configuration: config, animated: true)
return
}
mainStatusTranslation = translation
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .mainStatus))
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
} }
extension ConversationCollectionViewController { extension ConversationCollectionViewController {

View File

@ -112,7 +112,7 @@ class ConversationViewController: UIViewController {
appearance.configureWithDefaultBackground() appearance.configureWithDefaultBackground()
navigationItem.scrollEdgeAppearance = appearance navigationItem.scrollEdgeAppearance = appearance
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
} }
private func updateVisibilityBarButtonItem() { private func updateVisibilityBarButtonItem() {
@ -145,6 +145,8 @@ class ConversationViewController: UIViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String], let statusIDs = userInfo["statusIDs"] as? [String],
case .localID(let mainStatusID) = mode else { case .localID(let mainStatusID) = mode else {
return return

View File

@ -59,7 +59,14 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController! resultsController.exploreNavigationController = self.navigationController!
searchController = MastodonSearchController(searchResultsController: resultsController) searchController = UISearchController(searchResultsController: resultsController)
searchController.searchResultsUpdater = resultsController
if #available(iOS 16.0, *) {
searchController.scopeBarActivation = .onSearchActivation
}
searchController.searchBar.autocapitalizationType = .none
searchController.searchBar.delegate = resultsController
searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title)
definesPresentationContext = true definesPresentationContext = true
navigationItem.searchController = searchController navigationItem.searchController = searchController
@ -81,6 +88,13 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
) )
.sink { [unowned self] in self.updateHashtagsSection(followed: $0) } .sink { [unowned self] in self.updateHashtagsSection(followed: $0) }
.store(in: &cancellables) .store(in: &cancellables)
let a = PassthroughSubject<Int, Never>()
let b = PassthroughSubject<Int, Never>()
a.merge(with: b)
.sink(receiveValue: { print($0) })
.store(in: &cancellables)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -261,16 +275,13 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
} }
private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) { private func deleteList(_ list: List, completion: @escaping (Bool) -> Void) {
Task { Task { @MainActor in
let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) let service = DeleteListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
if await service.run() { if await service.run() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteItems([.list(list)]) snapshot.deleteItems([.list(list)])
await MainActor.run { await dataSource.apply(snapshot)
dataSource.apply(snapshot, animatingDifferences: true) { completion(true)
completion(true)
}
}
} else { } else {
completion(false) completion(false)
} }

View File

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

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="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_12" orientation="portrait" appearance="light"/> <device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21678"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="collection view cell content view" minToolsVersion="11.0"/> <capability name="collection view cell content view" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -56,7 +56,7 @@
<color key="textColor" systemColor="secondaryLabelColor"/> <color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalHuggingPriority="249" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" verticalCompressionResistancePriority="749" scrollEnabled="NO" editable="NO" textAlignment="natural" selectable="NO" translatesAutoresizingMaskIntoConstraints="NO" id="FJh-fd-fo8" customClass="StatusContentTextView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="8" y="147" width="431" height="170"/> <rect key="frame" x="8" y="147" width="431" height="170"/>
<string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string> <string key="text">Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda.</string>
<color key="textColor" systemColor="labelColor"/> <color key="textColor" systemColor="labelColor"/>
@ -113,7 +113,7 @@
<resources> <resources>
<image name="clock.arrow.circlepath" catalog="system" width="128" height="112"/> <image name="clock.arrow.circlepath" catalog="system" width="128" height="112"/>
<systemColor name="labelColor"> <systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor> </systemColor>
<systemColor name="secondaryLabelColor"> <systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>

View File

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

View File

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

View File

@ -62,7 +62,9 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -102,7 +104,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -146,6 +148,8 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }

View File

@ -166,9 +166,7 @@ class FastAccountSwitcherViewController: UIViewController {
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
hide() { hide() {
if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate { (self.view.window!.windowScene!.delegate as! MainSceneDelegate).showAddAccount()
sceneDelegate.showAddAccount()
}
} }
} else { } else {
let account = UserAccountsManager.shared.accounts[newIndex - 1] let account = UserAccountsManager.shared.accounts[newIndex - 1]
@ -180,9 +178,7 @@ class FastAccountSwitcherViewController: UIViewController {
selectionChangedFeedbackGenerator = nil selectionChangedFeedbackGenerator = nil
hide() { hide() {
if let sceneDelegate = self.view.window?.windowScene?.delegate as? MainSceneDelegate { (self.view.window!.windowScene!.delegate as! MainSceneDelegate).activateAccount(account, animated: true)
sceneDelegate.activateAccount(account, animated: true)
}
} }
} else { } else {
hide() hide()

View File

@ -8,7 +8,6 @@
import UIKit import UIKit
import UserAccounts import UserAccounts
import WebURL
class FastSwitchingAccountView: UIView { class FastSwitchingAccountView: UIView {
@ -127,11 +126,7 @@ class FastSwitchingAccountView: UIView {
private func setupAccount(account: UserAccountInfo) { private func setupAccount(account: UserAccountInfo) {
usernameLabel.text = account.username usernameLabel.text = account.username
if let domain = WebURL.Domain(account.instanceURL.host!) { instanceLabel.text = account.instanceURL.host!
instanceLabel.text = domain.render(.uncheckedUnicodeString)
} else {
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,
@ -145,7 +140,7 @@ class FastSwitchingAccountView: UIView {
} }
} }
accessibilityLabel = "\(account.username!)@\(instanceLabel.text!)" accessibilityLabel = "\(account.username!)@\(account.instanceURL.host!)"
} }
private func setupPlaceholder() { private func setupPlaceholder() {

View File

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

View File

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

View File

@ -25,14 +25,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
} }
@objc func handleGesture(_ recognizer: UIPanGestureRecognizer) { @objc func handleGesture(_ recognizer: UIPanGestureRecognizer) {
guard let recognizerSuperview = recognizer.view?.superview else { let translation = recognizer.translation(in: recognizer.view!.superview!)
// Assume the gesture has ended b/c we don't have a view/superview anymore.
inProgress = false
direction = nil
cancel()
return
}
let translation = recognizer.translation(in: recognizerSuperview)
var progress = translation.y / 200 var progress = translation.y / 200
if let direction = direction { if let direction = direction {
progress *= direction progress *= direction
@ -70,7 +63,7 @@ class LargeImageInteractionController: UIPercentDrivenInteractiveTransition {
override func cancel() { override func cancel() {
super.cancel() super.cancel()
viewController?.isInteractivelyAnimatingDismissal = false viewController.isInteractivelyAnimatingDismissal = false
} }
} }

View File

@ -10,21 +10,18 @@ import UIKit
import Pachyderm import Pachyderm
import Combine import Combine
class EditListAccountsViewController: UIViewController, CollectionViewController { class EditListAccountsViewController: EnhancedTableViewController {
private var list: List private var list: List
private let mastodonController: MastodonController let mastodonController: MastodonController
private var state = State.unloaded var changedAccounts = false
private(set) var changedAccounts = false var dataSource: DataSource!
var nextRange: RequestRange?
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! var searchResultsController: SearchResultsViewController!
var collectionView: UICollectionView! { view as? UICollectionView } var searchController: UISearchController!
private var nextRange: RequestRange?
private var searchResultsController: SearchResultsViewController!
private var searchController: UISearchController!
private var listRenamedCancellable: AnyCancellable? private var listRenamedCancellable: AnyCancellable?
@ -32,12 +29,13 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
self.list = list self.list = list
self.mastodonController = mastodonController self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil) super.init(style: .plain)
listChanged() listChanged()
listRenamedCancellable = mastodonController.$lists listRenamedCancellable = mastodonController.$lists
.compactMap { $0.first { $0.id == list.id } } .compactMap { $0.first { $0.id == list.id } }
.removeDuplicates(by: { $0.title == $1.title })
.sink { [unowned self] in .sink { [unowned self] in
self.list = $0 self.list = $0
self.listChanged() self.listChanged()
@ -48,45 +46,29 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
fatalError("init(coder:) has not been implemeneted") fatalError("init(coder:) has not been implemeneted")
} }
override func loadView() {
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
var config = sectionConfig
switch dataSource.itemIdentifier(for: indexPath)! {
case .loadingIndicator:
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
case .account(id: _):
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
}
return config
}
config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in
switch dataSource.itemIdentifier(for: indexPath) {
case .account(id: let id):
let remove = UIContextualAction(style: .destructive, title: "Remove") { [unowned self] _, _, completion in
Task {
await self.removeAccount(id: id)
completion(true)
}
}
return UISwipeActionsConfiguration(actions: [remove])
default:
return nil
}
}
let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.allowsSelection = false
collectionView.backgroundColor = .appGroupedBackground
dataSource = createDataSource()
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell")
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 66
tableView.allowsSelection = false
tableView.backgroundColor = .appGroupedBackground
dataSource = DataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
guard case let .account(id) = item else { fatalError() }
let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell
cell.delegate = self
cell.updateUI(accountID: id)
cell.configurationUpdateHandler = { cell, state in
cell.backgroundConfiguration = .appListGroupedCell(for: state)
}
return cell
})
dataSource.editListAccountsController = self
searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people) searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people)
searchResultsController.following = true searchResultsController.following = true
searchResultsController.delegate = self searchResultsController.delegate = self
@ -102,103 +84,23 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
navigationItem.hidesSearchBarWhenScrolling = false navigationItem.hidesSearchBarWhenScrolling = false
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
navigationItem.preferredSearchBarPlacement = .stacked navigationItem.preferredSearchBarPlacement = .stacked
navigationItem.renameDelegate = self
navigationItem.titleMenuProvider = { [unowned self] suggested in
var children = suggested
children.append(contentsOf: self.listSettingsMenuElements())
return UIMenu(children: children)
}
} else {
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [
// uncached so that menu always reflects the current state of the list
UIDeferredMenuElement.uncached({ [unowned self] elementHandler in
var elements = self.listSettingsMenuElements()
elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in
RenameListService(list: self.list, mastodonController: self.mastodonController, present: {
self.present($0, animated: true)
}).run()
}), at: 0)
elementHandler(elements)
})
]))
} }
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, indexPath, itemIdentifier in
cell.indicator.startAnimating()
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(accountID: itemIdentifier)
cell.configurationUpdateHandler = { cell, state in
cell.backgroundConfiguration = .appListGroupedCell(for: state)
}
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .loadingIndicator:
return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ())
case .account(id: let id):
return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id)
}
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated) navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed))
Task { Task {
await loadAccounts() await loadAccounts()
} }
} }
private func listChanged() { private func listChanged() {
title = list.title title = String(format: NSLocalizedString("Edit %@", comment: "edit list screen title"), list.title)
} }
private func listSettingsMenuElements() -> [UIMenuElement] { func loadAccounts() async {
var elements = [UIMenuElement]()
if mastodonController.instanceFeatures.listRepliesPolicy {
let actions = List.ReplyPolicy.allCases.map { policy in
UIAction(title: policy.actionTitle, state: list.replyPolicy == policy ? .on : .off) { [unowned self] _ in
self.setReplyPolicy(policy)
}
}
elements.append(UIMenu(title: "Show replies…", image: UIImage(systemName: "arrowshape.turn.up.left"), children: actions))
}
if mastodonController.instanceFeatures.exclusiveLists {
let actions = [
UIAction(title: "Hidden from Home", state: list.exclusive == true ? .on : .off) { [unowned self] _ in
self.setExclusive(true)
},
UIAction(title: "Shown on Home", state: list.exclusive == false ? .on : .off) { [unowned self] _ in
self.setExclusive(false)
},
]
elements.append(UIMenu(title: "Posts from this list are…", children: actions))
}
return elements
}
@MainActor
private func loadAccounts() async {
guard state == .unloaded else { return }
state = .loading
async let results = try await mastodonController.run(List.getAccounts(list.id))
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.accounts])
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
do { do {
let (accounts, pagination) = try await results let request = List.getAccounts(list.id)
let (accounts, pagination) = try await mastodonController.run(request)
self.nextRange = pagination?.older self.nextRange = pagination?.older
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
@ -207,61 +109,20 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
} }
} }
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = self.dataSource.snapshot()
snapshot.appendSections([.accounts]) if snapshot.indexOfSection(.accounts) == nil {
snapshot.appendSections([.accounts])
} else {
snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts))
}
snapshot.appendItems(accounts.map { .account(id: $0.id) }) snapshot.appendItems(accounts.map { .account(id: $0.id) })
await dataSource.apply(snapshot) await dataSource.apply(snapshot)
state = .loaded
} catch { } catch {
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self.loadAccounts() await self.loadAccounts()
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
state = .unloaded
await dataSource.apply(.init())
}
}
private func loadNextPage() async {
guard state == .loaded,
let nextRange else { return }
state = .loading
async let results = try await mastodonController.run(List.getAccounts(list.id, range: nextRange))
let origSnapshot = dataSource.snapshot()
var snapshot = origSnapshot
snapshot.appendItems([.loadingIndicator])
await dataSource.apply(snapshot)
do {
let (accounts, pagination) = try await results
self.nextRange = pagination?.older
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(accounts: accounts) {
continuation.resume()
}
}
var snapshot = origSnapshot
snapshot.appendItems(accounts.map { .account(id: $0.id) })
await dataSource.apply(snapshot)
state = .loaded
} catch {
let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
await self.loadNextPage()
}
self.showToast(configuration: config, animated: true)
state = .loaded
await dataSource.apply(origSnapshot)
} }
} }
@ -295,30 +156,19 @@ class EditListAccountsViewController: UIViewController, CollectionViewController
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
} }
private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) { // MARK: - Table view delegate
Task {
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
await service.run(replyPolicy: replyPolicy) return .delete
}
} }
private func setExclusive(_ exclusive: Bool) { // MARK: - Interaction
Task {
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) @objc func renameButtonPressed() {
await service.run(exclusive: exclusive) RenameListService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }).run()
}
}
}
extension EditListAccountsViewController {
enum State {
case unloaded
case loading
case loaded
case loadingOlder
} }
} }
extension EditListAccountsViewController { extension EditListAccountsViewController {
@ -326,17 +176,24 @@ extension EditListAccountsViewController {
case accounts case accounts
} }
enum Item: Hashable { enum Item: Hashable {
case loadingIndicator
case account(id: String) case account(id: String)
} }
}
class DataSource: UITableViewDiffableDataSource<Section, Item> {
extension EditListAccountsViewController: UICollectionViewDelegate { weak var editListAccountsController: EditListAccountsViewController?
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
if state == .loaded, override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 { return true
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
guard editingStyle == .delete,
case let .account(id) = itemIdentifier(for: indexPath) else {
return
}
Task { Task {
await loadNextPage() await self.editListAccountsController?.removeAccount(id: id)
} }
} }
} }
@ -359,29 +216,3 @@ extension EditListAccountsViewController: SearchResultsViewControllerDelegate {
} }
} }
} }
extension EditListAccountsViewController: UINavigationItemRenameDelegate {
func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool {
!title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) {
Task {
let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) })
await service.run(title: title)
}
}
}
private extension List.ReplyPolicy {
var actionTitle: String {
switch self {
case .followed:
"To accounts you follow"
case .list:
"To other list members"
case .none:
"Never"
}
}
}

View File

@ -59,7 +59,7 @@ class ListTimelineViewController: TimelineViewController {
func presentEdit(animated: Bool) { func presentEdit(animated: Bool) {
let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController) let editListAccountsController = EditListAccountsViewController(list: list, mastodonController: mastodonController)
editListAccountsController.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed)) editListAccountsController.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(editListDoneButtonPressed))
let navController = UINavigationController(rootViewController: editListAccountsController) let navController = UINavigationController(rootViewController: editListAccountsController)
present(navController, animated: animated) present(navController, animated: animated)
} }

View File

@ -68,7 +68,9 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section return section
} }
view = UICollectionView(frame: .zero, collectionViewLayout: layout) view = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -107,7 +109,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh \(predicateTitle)"))
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: mastodonController.persistentContainer.viewContext)
} }
@ -205,6 +207,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }

View File

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

View File

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

View File

@ -117,8 +117,6 @@ class MainSplitViewController: UISplitViewController {
guard mode != navigationMode else { guard mode != navigationMode else {
return return
} }
navigationMode = mode
let viewControllers = secondaryNavController.viewControllers let viewControllers = secondaryNavController.viewControllers
secondaryNavController.viewControllers = [] secondaryNavController.viewControllers = []
// Setting viewControllers = [] doesn't remove the VC views from their superviews immediately, // Setting viewControllers = [] doesn't remove the VC views from their superviews immediately,
@ -214,10 +212,6 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
if item == sidebar.selectedItem { if item == sidebar.selectedItem {
itemNavStack = secondaryNavController.viewControllers itemNavStack = secondaryNavController.viewControllers
secondaryNavController.viewControllers = [] secondaryNavController.viewControllers = []
// Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy
for vc in itemNavStack {
vc.viewIfLoaded?.removeFromSuperview()
}
} else { } else {
itemNavStack = navigationStacks[item] ?? [] itemNavStack = navigationStacks[item] ?? []
navigationStacks.removeValue(forKey: item) navigationStacks.removeValue(forKey: item)
@ -343,11 +337,6 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst) let viewControllersToMove = navController.viewControllers.dropFirst(skipFirst)
navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst) navController.viewControllers.removeLast(navController.viewControllers.count - skipFirst)
// Sometimes removing a VC from the viewControllers array doesn't immediately remove it's view from the hierarchy
for vc in viewControllersToMove {
vc.viewIfLoaded?.removeFromSuperview()
}
if let prepend = prepend { if let prepend = prepend {
navigationStacks[item] = [prepend] + viewControllersToMove navigationStacks[item] = [prepend] + viewControllersToMove
} else { } else {

View File

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

View File

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

View File

@ -23,7 +23,6 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
$0.contentMode = .scaleAspectFill $0.contentMode = .scaleAspectFill
$0.layer.masksToBounds = true $0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous $0.layer.cornerCurve = .continuous
$0.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
$0.widthAnchor.constraint(equalTo: $0.heightAnchor), $0.widthAnchor.constraint(equalTo: $0.heightAnchor),
]) ])

View File

@ -25,8 +25,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
private(set) var collectionView: UICollectionView! private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var newer: RequestRange? private var newer: RequestRange?
private var older: RequestRange? private var older: RequestRange?
@ -93,7 +91,9 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section return section
} }
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -121,7 +121,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
self.reapplyFilters(actionsChanged: actionsChanged) self.reapplyFilters(actionsChanged: actionsChanged)
} }
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -257,6 +257,8 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -537,10 +539,6 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 { if indexPath.row == itemsInSection - 1 {
Task { Task {
// Because of grouping, all cells from the first load may fit on screen,
// in which case, we try to load older while still in the loadingInitial state.
// So, wait for that to finish before trying to load more.
await controller.finishPendingOperation()
await controller.loadOlder() await controller.loadOlder()
} }
} }
@ -664,13 +662,6 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
} }
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
} }
extension NotificationsCollectionViewController: UICollectionViewDragDelegate { extension NotificationsCollectionViewController: UICollectionViewDragDelegate {

View File

@ -150,8 +150,7 @@ class InstanceSelectorTableViewController: UITableViewController {
private func updateSpecificInstance(domain: String) { private func updateSpecificInstance(domain: String) {
activityIndicator.startAnimating() activityIndicator.startAnimating()
guard let components = parseURLComponents(input: domain), guard let components = parseURLComponents(input: domain) else {
let url = components.url else {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
snapshot.deleteSections([.selected]) snapshot.deleteSections([.selected])
@ -160,9 +159,10 @@ class InstanceSelectorTableViewController: UITableViewController {
activityIndicator.stopAnimating() activityIndicator.stopAnimating()
return return
} }
let url = components.url!
let client = Client(baseURL: url, session: .appDefault) let client = Client(baseURL: url, session: .appDefault)
let request = Client.getInstanceV1() let request = Client.getInstance()
client.run(request) { (response) in client.run(request) { (response) in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if snapshot.indexOfSection(.selected) != nil { if snapshot.indexOfSection(.selected) != nil {
@ -309,7 +309,7 @@ extension InstanceSelectorTableViewController {
case recommendedInstances case recommendedInstances
} }
enum Item: Equatable, Hashable { enum Item: Equatable, Hashable {
case selected(URL, InstanceV1) case selected(URL, Instance)
case recommended(InstanceSelector.Instance) case recommended(InstanceSelector.Instance)
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {

View File

@ -90,11 +90,11 @@ struct AppearancePrefsView : View {
@ViewBuilder @ViewBuilder
private var interfaceSection: some View { private var interfaceSection: some View {
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if preferences.hasFeatureFlag(.iPadNavigationMode),
UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Section(header: Text("Interface")) { Section(header: Text("Interface")) {
WidescreenNavigationPrefsView() WidescreenNavigationPrefsView()
} }
.appGroupedListRowBackground()
} }
} }
@ -118,17 +118,11 @@ struct AppearancePrefsView : View {
Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) { Toggle(isOn: $preferences.alwaysShowStatusVisibilityIcon) {
Text("Always Show Status Visibility Icons") Text("Always Show Status Visibility Icons")
} }
Toggle(isOn: $preferences.showLinkPreviews) {
Text("Show Link Previews")
}
Toggle(isOn: $preferences.showAttachmentsInTimeline) {
Text("Show Attachments on Timeline")
}
Toggle(isOn: $preferences.hideActionsInTimeline) { Toggle(isOn: $preferences.hideActionsInTimeline) {
Text("Hide Actions on Timeline") Text("Hide Actions on Timeline")
} }
Toggle(isOn: $preferences.underlineTextLinks) { Toggle(isOn: $preferences.showLinkPreviews) {
Text("Underline Links") Text("Show Link Previews")
} }
NavigationLink("Leading Swipe Actions") { NavigationLink("Leading Swipe Actions") {
SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions) SwipeActionsPrefsView(selection: $preferences.leadingStatusSwipeActions)

View File

@ -28,11 +28,9 @@ struct ComposingPrefsView: View {
var visibilitySection: some View { var visibilitySection: some View {
Section { Section {
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
ForEach(PostVisibility.allCases, id: \.self) { visibility in ForEach(Visibility.allCases, id: \.self) { visibility in
HStack { HStack {
if let imageName = visibility.imageName { Image(systemName: visibility.imageName)
Image(systemName: imageName)
}
Text(visibility.displayName) Text(visibility.displayName)
} }
.tag(visibility) .tag(visibility)
@ -40,7 +38,7 @@ struct ComposingPrefsView: View {
// navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291 // navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291
} }
Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) { Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) {
ForEach(ReplyVisibility.allCases, id: \.self) { visibility in ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in
HStack { HStack {
if let imageName = visibility.imageName { if let imageName = visibility.imageName {
Image(systemName: imageName) Image(systemName: imageName)

View File

@ -7,7 +7,6 @@
import SwiftUI import SwiftUI
import UserAccounts import UserAccounts
import WebURL
struct PreferencesView: View { struct PreferencesView: View {
let mastodonController: MastodonController let mastodonController: MastodonController
@ -42,12 +41,7 @@ struct PreferencesView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(verbatim: account.username) Text(verbatim: account.username)
.foregroundColor(.primary) .foregroundColor(.primary)
let instance = if let domain = WebURL.Domain(account.instanceURL.host!) { Text(verbatim: account.instanceURL.host!)
domain.render(.uncheckedUnicodeString)
} else {
account.instanceURL.host!
}
Text(verbatim: instance)
.font(.caption) .font(.caption)
.foregroundColor(.primary) .foregroundColor(.primary)
} }

View File

@ -32,8 +32,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell? private(set) var headerCell: ProfileHeaderCollectionViewCell?
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private(set) var state: State = .unloaded private(set) var state: State = .unloaded
init(accountID: String?, kind: Kind, owner: ProfileViewController) { init(accountID: String?, kind: Kind, owner: ProfileViewController) {
@ -67,25 +65,18 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
} }
config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in
guard let item = self.dataSource.itemIdentifier(for: indexPath), guard let item = self.dataSource.itemIdentifier(for: indexPath) else {
let section = self.dataSource.sectionIdentifier(for: indexPath.section) else {
return sectionSeparatorConfiguration return sectionSeparatorConfiguration
} }
var config = sectionSeparatorConfiguration var config = sectionSeparatorConfiguration
if item.hideSeparators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} else if section == .header {
config.topSeparatorVisibility = .hidden
config.bottomSeparatorInsets = .zero
} else if indexPath.row == 0 && (section == .pinned || section == .entries) {
// TODO: row == 0 isn't technically right, the top post could be filtered out
config.topSeparatorInsets = .zero
} else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item, } else if case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item,
filterer.isKnownHide(state: filterState) { filterer.isKnownHide(state: filterState) {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} else { } else if case .status(_, _, _, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
} }
@ -95,11 +86,12 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
if case .header = dataSource.sectionIdentifier(for: sectionIndex) { if case .header = dataSource.sectionIdentifier(for: sectionIndex) {
var config = UICollectionLayoutListConfiguration(appearance: .plain) var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground config.backgroundColor = .appBackground
config.separatorConfiguration.bottomSeparatorInsets = .zero
return .list(using: config, layoutEnvironment: environment) return .list(using: config, layoutEnvironment: environment)
} else { } else {
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section return section
} }
} }
@ -156,7 +148,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.reapplyFilters(actionsChanged: actionsChanged) self.reapplyFilters(actionsChanged: actionsChanged)
} }
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -384,6 +376,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -633,13 +627,6 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
return true return true
} }
} }
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
} }
extension ProfileStatusesViewController: UICollectionViewDragDelegate { extension ProfileStatusesViewController: UICollectionViewDragDelegate {

View File

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

View File

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

View File

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

View File

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

View File

@ -84,7 +84,7 @@ class StatusActionAccountListViewController: UIViewController {
view.backgroundColor = .appBackground view.backgroundColor = .appBackground
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
@ -99,6 +99,8 @@ class StatusActionAccountListViewController: UIViewController {
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }

View File

@ -61,34 +61,13 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
} }
private lazy var contentContainer = StatusContentContainer(arrangedSubviews: [ private let contentContainer = StatusContentContainer<StatusEditContentTextView, StatusEditPollView>(useTopSpacer: false).configure {
contentTextView, $0.contentTextView.defaultFont = TimelineStatusCollectionViewCell.contentFont
cardView, $0.contentTextView.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
attachmentsView, $0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
pollView,
] as! [any StatusContentView], useTopSpacer: false).configure {
$0.setContentHuggingPriority(.defaultLow, for: .vertical) $0.setContentHuggingPriority(.defaultLow, for: .vertical)
} }
private let contentTextView = StatusEditContentTextView().configure {
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil
$0.isEditable = false
$0.isSelectable = false
$0.defaultFont = TimelineStatusCollectionViewCell.contentFont
$0.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont
$0.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
}
private let cardView = StatusCardView().configure {
$0.heightAnchor.constraint(equalToConstant: StatusContentContainer.cardViewHeight).isActive = true
}
private let attachmentsView = AttachmentsContainerView()
private let pollView = StatusEditPollView()
weak var delegate: StatusEditCollectionViewCellDelegate? weak var delegate: StatusEditCollectionViewCellDelegate?
private var mastodonController: MastodonController! { delegate?.apiController } private var mastodonController: MastodonController! { delegate?.apiController }
@ -112,76 +91,8 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK: Accessibility // todo: accessibility
override var isAccessibilityElement: Bool {
get { true }
set {}
}
override var accessibilityAttributedLabel: NSAttributedString? {
get {
var str: AttributedString = ""
if statusState.collapsed ?? false {
if !edit.spoilerText.isEmpty {
str += AttributedString(edit.spoilerText)
str += ", "
}
str += "collapsed"
} else {
str += AttributedString(contentTextView.attributedText)
if edit.attachments.count > 0 {
let includeDescriptions: Bool
switch Preferences.shared.attachmentBlurMode {
case .useStatusSetting:
includeDescriptions = !Preferences.shared.blurMediaBehindContentWarning || edit.spoilerText.isEmpty
case .always:
includeDescriptions = true
case .never:
includeDescriptions = false
}
if includeDescriptions {
if edit.attachments.count == 1 {
let attachment = edit.attachments[0]
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment: \(desc)")
} else {
for (index, attachment) in edit.attachments.enumerated() {
let desc = attachment.description?.isEmpty == false ? attachment.description! : "no description"
str += AttributedString(", attachment \(index + 1): \(desc)")
}
}
} else {
str += AttributedString(", \(edit.attachments.count) attachment\(edit.attachments.count == 1 ? "" : "s")")
}
}
if edit.poll != nil {
str += ", poll"
}
}
return NSAttributedString(str)
}
set {}
}
override var accessibilityHint: String? {
get {
if statusState.collapsed ?? false {
return "Double tap to expand the post."
} else {
return nil
}
}
set {}
}
override func accessibilityActivate() -> Bool {
if statusState.collapsed ?? false {
collapseButtonPressed()
}
return true
}
// MARK: Configure UI // MARK: Configure UI
@ -191,13 +102,13 @@ class StatusEditCollectionViewCell: UICollectionViewListCell {
timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt) timestampLabel.text = ConversationMainStatusCollectionViewCell.dateFormatter.string(from: edit.createdAt)
contentTextView.setTextFrom(edit: edit, index: index) contentContainer.contentTextView.setTextFrom(edit: edit, index: index)
contentTextView.navigationDelegate = delegate contentContainer.contentTextView.navigationDelegate = delegate
attachmentsView.delegate = self contentContainer.attachmentsView.delegate = self
attachmentsView.updateUI(attachments: edit.attachments) contentContainer.attachmentsView.updateUI(attachments: edit.attachments)
pollView.isHidden = edit.poll == nil contentContainer.pollView.isHidden = edit.poll == nil
pollView.updateUI(poll: edit.poll, emojis: edit.emojis) contentContainer.pollView.updateUI(poll: edit.poll, emojis: edit.emojis)
cardView.isHidden = true contentContainer.cardView.isHidden = true
contentWarningLabel.text = edit.spoilerText contentWarningLabel.text = edit.spoilerText
contentWarningLabel.isHidden = edit.spoilerText.isEmpty contentWarningLabel.isHidden = edit.spoilerText.isEmpty
@ -240,9 +151,9 @@ extension StatusEditCollectionViewCell: AttachmentViewDelegate {
guard let delegate else { guard let delegate else {
return nil return nil
} }
let attachments = attachmentsView.attachments! let attachments = contentContainer.attachmentsView.attachments!
let sourceViews = attachments.map { let sourceViews = attachments.map {
attachmentsView.getAttachmentView(for: $0) contentContainer.attachmentsView.getAttachmentView(for: $0)
} }
let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index) let gallery = delegate.gallery(attachments: attachments, sourceViews: sourceViews, startIndex: index)
return gallery return gallery

View File

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

View File

@ -9,7 +9,7 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class StatusEditPollView: UIStackView, StatusContentView { class StatusEditPollView: UIStackView, StatusContentPollView {
private var titleLabels: [EmojiLabel] = [] private var titleLabels: [EmojiLabel] = []

View File

@ -20,8 +20,6 @@ class TimelineJumpButton: UIView {
var config = UIButton.Configuration.plain() var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "arrow.up") config.image = UIImage(systemName: "arrow.up")
config.contentInsets = .zero config.contentInsets = .zero
// We don't want a background for this button, even when accessibility button shapes are enabled, because it's in the navbar.
config.background.backgroundColor = .clear
return UIButton(configuration: config) return UIButton(configuration: config)
}() }()

View File

@ -42,8 +42,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var collectionView: UICollectionView! private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>() private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>()
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing // the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
@ -109,10 +107,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
return config return config
} }
// Use the sectionProvider closure, because the content inset depends on the environment. // just setting layout.configuration.contentInsetsReference doesn't work with UICollectionViewCompositionalLayout.list
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
section.contentInsetsReference = .readableContent
}
return section return section
} }
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
@ -162,19 +162,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
Preferences.shared.$showAttachmentsInTimeline
// skip the initial value
.dropFirst()
// publisher fires on willChange, wait the change is actually made
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in
var snapshot = self.dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers)
self.dataSource.apply(snapshot, animatingDifferences: false)
}
.store(in: &cancellables)
if userActivity != nil { if userActivity != nil {
userActivityNeedsUpdate userActivityNeedsUpdate
@ -192,7 +180,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) { func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self cell.delegate = self
cell.showAttachmentsInline = Preferences.shared.showAttachmentsInTimeline
if case .home = timeline { if case .home = timeline {
cell.showFollowedHashtags = true cell.showFollowedHashtags = true
} else { } else {
@ -420,7 +407,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
await applyItemsToRestore(position: position) applyItemsToRestore(position: position)
loaded = true loaded = true
} }
case .mastodon: case .mastodon:
@ -443,7 +430,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
position.centerStatusID = centerStatusID position.centerStatusID = centerStatusID
let hasStatusesToRestore = await loadStatusesToRestore(position: position) let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore { if hasStatusesToRestore {
await applyItemsToRestore(position: position) applyItemsToRestore(position: position)
} }
mastodonController.persistentContainer.viewContext.delete(position) mastodonController.persistentContainer.viewContext.delete(position)
} }
@ -453,25 +440,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
let originalPositionStatusIDs = position.statusIDs let originalPositionStatusIDs = position.statusIDs
var unloaded = [String]() let unloaded = position.statusIDs.filter({ mastodonController.persistentContainer.status(for: $0) == nil })
for id in position.statusIDs {
if let status = mastodonController.persistentContainer.status(for: id) {
// touch the status so that, even if it's old, it doesn't get pruned when we go into the background
status.touch()
// there was a bug where th the reblogged status would get pruned even when it was still refernced by the reblog
// as a temporary workaround, until there are no longer user db's in this state,
// check if the reblog is invalid and reload the status if so
if let reblog = status.reblog,
// force the fault to fire
case _ = reblog.id,
reblog.isDeleted {
unloaded.append(id)
}
} else {
unloaded.append(id)
}
}
guard !unloaded.isEmpty else { guard !unloaded.isEmpty else {
return true return true
} }
@ -532,7 +501,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
@MainActor @MainActor
private func applyItemsToRestore(position: TimelinePosition) async { private func applyItemsToRestore(position: TimelinePosition) {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.statuses])
let statusIDs = position.statusIDs let statusIDs = position.statusIDs
@ -545,13 +514,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
"statusIDs": position.statusIDs "statusIDs": position.statusIDs
] ]
SentrySDK.addBreadcrumb(crumb) SentrySDK.addBreadcrumb(crumb)
await apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false) {
if let centerStatusID, if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID) { let index = statusIDs.firstIndex(of: centerStatusID) {
self.scrollToItem(item: items[index]) self.scrollToItem(item: items[index])
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)") stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else { } else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID") stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}
} }
} }
@ -596,11 +566,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
var count = 0 var count = 0
while count < 5 { while count < 5 {
count += 1 count += 1
let crumb = Breadcrumb(level: .info, category: "TimelineViewController")
crumb.message = "scrollToItem, attempt=\(count)"
SentrySDK.addBreadcrumb(crumb)
let origOffset = self.collectionView.contentOffset let origOffset = self.collectionView.contentOffset
self.collectionView.layoutIfNeeded() self.collectionView.layoutIfNeeded()
self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false)
@ -753,11 +718,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@objc func refresh() { @objc func refresh() {
Task { @MainActor in Task { @MainActor in
if case .idle = controller.state, if case .notLoadedInitial = controller.state {
!dataSource.snapshot().itemIdentifiers(inSection: .statuses).isEmpty {
await controller.loadNewer()
} else {
await controller.loadInitial() await controller.loadInitial()
} else {
await controller.loadNewer()
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
@ -965,6 +929,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) { @objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo, guard let userInfo = notification.userInfo,
let accountID = mastodonController.accountInfo?.id,
userInfo["accountID"] as? String == accountID,
let statusIDs = userInfo["statusIDs"] as? [String] else { let statusIDs = userInfo["statusIDs"] as? [String] else {
return return
} }
@ -1191,10 +1157,7 @@ extension TimelineViewController {
let addedItems: Bool let addedItems: Bool
let statusItems = snapshot.itemIdentifiers(inSection: .statuses) let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
guard let gapIndex = statusItems.firstIndex(of: .gap) else { let gapIndex = statusItems.firstIndex(of: .gap)!
// Not sure how this is reachable (maybe the gap cell was tapped twice and the requests raced?) but w/e
return
}
switch direction { switch direction {
case .above: case .above:
@ -1321,9 +1284,6 @@ extension TimelineViewController: UICollectionViewDelegate {
selected(status: status.reblog?.id ?? id, state: collapseState.copy()) selected(status: status.reblog?.id ?? id, state: collapseState.copy())
} }
case .gap: case .gap:
guard controller.state == .idle else {
return
}
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true cell.showsIndicator = true
Task { Task {
@ -1357,11 +1317,6 @@ extension TimelineViewController: UICollectionViewDelegate {
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
userActivityNeedsUpdate.send() userActivityNeedsUpdate.send()
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
} }
func scrollViewDidScroll(_ scrollView: UIScrollView) { func scrollViewDidScroll(_ scrollView: UIScrollView) {

View File

@ -0,0 +1,92 @@
//
// EnhancedTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/10/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import SafariServices
class EnhancedTableViewController: UITableViewController {
var dragEnabled = false
override func viewDidLoad() {
super.viewDidLoad()
if dragEnabled {
tableView.dragDelegate = self
}
}
// MARK: Table View Delegate
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let cell = tableView.cellForRow(at: indexPath) as? SelectableTableViewCell {
cell.didSelectCell()
}
}
}
extension EnhancedTableViewController {
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
if let cell = tableView.cellForRow(at: indexPath) as? UITableViewCell & MenuPreviewProvider {
let cellLocation = cell.convert(point, from: tableView)
guard let (previewProvider, actionsProvider) = cell.getPreviewProviders(for: cellLocation, sourceViewController: self) else {
return nil
}
let actionProvider: UIContextMenuActionProvider = { (_) in
let suggested = self.getSuggestedContextMenuActions(tableView: tableView, indexPath: indexPath, point: point)
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: suggested + actionsProvider())
}
return UIContextMenuConfiguration(identifier: nil, previewProvider: previewProvider, actionProvider: actionProvider)
} else {
return nil
}
}
// todo: replace this with the UIKit suggested actions, if possible
@objc open func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
return []
}
override func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
if let viewController = animator.previewViewController {
animator.preferredCommitStyle = .pop
animator.addCompletion {
if let customPresenting = viewController as? CustomPreviewPresenting {
customPresenting.presentFromPreview(presenter: self)
} else {
self.show(viewController, sender: nil)
}
}
}
}
}
extension EnhancedTableViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let cell = tableView.cellForRow(at: indexPath) as? DraggableTableViewCell else {
return []
}
return cell.dragItemsForBeginning(session: session)
}
}
extension EnhancedTableViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
tableView.scrollToTop()
}
}
extension EnhancedTableViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
tableView.scrollToTop()
return .stop
}
}

View File

@ -21,8 +21,6 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
var collectionView: UICollectionView! { get } var collectionView: UICollectionView! { get }
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get } var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
var reconfigureVisibleItemsOnEndDecelerating: Bool { get set }
} }
protocol TimelineLikeCollectionViewSection: Hashable, Sendable { protocol TimelineLikeCollectionViewSection: Hashable, Sendable {
@ -127,18 +125,6 @@ extension TimelineLikeCollectionViewController {
var config: ToastConfiguration var config: ToastConfiguration
if let error = error as? Self.Error, if let error = error as? Self.Error,
error == .allCaughtUp { error == .allCaughtUp {
// Reconfigure visible items to update timestamps.
#if targetEnvironment(macCatalyst)
let isRefreshing = false
#else
let isRefreshing = collectionView.refreshControl?.isRefreshing ?? false
#endif
if isRefreshing {
reconfigureVisibleItemsOnEndDecelerating = true
} else {
reconfigureVisibleCells()
}
config = ToastConfiguration(title: "You're all caught up") config = ToastConfiguration(title: "You're all caught up")
config.edge = .top config.edge = .top
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 2
@ -212,19 +198,10 @@ extension TimelineLikeCollectionViewController {
// apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods
// but we always want to update the data source on the main thread for consistency, so this method does that // but we always want to update the data source on the main thread for consistency, so this method does that
func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async { func apply(_ snapshot: NSDiffableDataSourceSnapshot<Section, Item>, animatingDifferences: Bool) async {
await MainActor.run { let task = Task { @MainActor in
dataSource?.apply(snapshot, animatingDifferences: animatingDifferences) self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences)
}
}
@MainActor
func reconfigureVisibleCells() {
let items = collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) }
if !items.isEmpty {
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
} }
await task.value
} }
func registerTimelineLikeCells() { func registerTimelineLikeCells() {

View File

@ -8,7 +8,6 @@
import Foundation import Foundation
import OSLog import OSLog
import Combine
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject { protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem: Sendable associatedtype TimelineItem: Sendable
@ -43,7 +42,7 @@ class TimelineLikeController<Item: Sendable> {
private unowned var delegate: any TimelineLikeControllerDelegate<Item> private unowned var delegate: any TimelineLikeControllerDelegate<Item>
private let ownerType: String private let ownerType: String
@AsyncObservable private(set) var state = State.notLoadedInitial { private(set) var state = State.notLoadedInitial {
willSet { willSet {
guard state.canTransition(to: newValue) else { guard state.canTransition(to: newValue) else {
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)") logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
@ -58,19 +57,6 @@ class TimelineLikeController<Item: Sendable> {
self.ownerType = ownerType self.ownerType = ownerType
} }
/// Waits for the controller to finish the current operation and arrive at the idle state.
///
/// If the current state is `notLoadedInitial`, this will wait until the controller
/// settles after the initial load.
func finishPendingOperation() async {
guard state != .idle else {
return
}
for await state in $state where state == .idle {
break
}
}
func loadInitial() async { func loadInitial() async {
guard state == .notLoadedInitial || state == .idle else { guard state == .notLoadedInitial || state == .idle else {
return return
@ -383,17 +369,3 @@ enum TimelineGapDirection {
} }
} }
} }
// I would love to be able to do this with @Observable, but it's not clear how to do so.
@propertyWrapper
private class AsyncObservable<Value>: ObservableObject {
@Published var wrappedValue: Value
var projectedValue: AsyncPublisher<Published<Value>.Publisher> {
$wrappedValue.values
}
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}

View File

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

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