Compare commits

..

No commits in common. "6c0564e0ee0015880cb6d1b6e7735df3047c040b" and "06f761bf56b188458b3b05f93abd602e7493f373" have entirely different histories.

62 changed files with 413 additions and 806 deletions

View File

@ -1,18 +1,3 @@
## 2023.6
This update fixes a number of bugs and improves stability throughout the app. See below for a list of fixes.
Bugfixes:
- Fix issues displaying main post in the Conversation screen
- Fix crash when opening the Compose screen in certain locales
- Fix issues when collapsing from sidebar to tab bar mode
- Fix incorrect UI being displayed when accessing certain parts of the app immediately after launch
- Fix link card images not being blurred on posts marked sensitive
- Fix links appearing with incorrect accent color intermittently
- Fix being unable to remove followed hashtags from the Explore screen
- Akkoma: Fix not being able to follow hashtags
- Pleroma: Fix refreshing Mentions failing
- iPhone: Fix ducked Compose screen disappearing when rotating on large phones
## 2023.5 ## 2023.5
This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes. This update adds new several Compose-related features, including the ability to edit posts, a share sheet extension, and a post language picker. See below for the full list of improvements and bugfixes.

View File

@ -1,37 +1,5 @@
# Changelog # Changelog
## 2023.6 (100)
Bugfixes:
- Fix Conversation main post flashing incorrect background color when touched
- Fix reblogs count button in Conversation main post not being left-aligned
- Fix Conversation main post flickering when context loaded
- Fix context menu not appearing when long pressing finished/voted poll
- Fix Tip Jar button width changing while purchasing
- Fix crash when opening Compose screen in certain locales
- Fix potential issue with Recognize Text context menu action on attachments
- Fix attachment deletion context menu action not working
- Fix crash when collapsing from sidebar to tab bar mode
- Fix crash when post deleted before Notifications screen is loaded
- Fix race conditions when accessing certain parts of the app immediately upon launch
- Fix crash when viewing invalid user post notifications
- Fix non-square avatars not displaying correctly in various places
- Fix incorrect context menu preview being shown on filtered posts
- Fix link card images not being blurred on sensitive posts
- Fix reblog confirmation alert showing incorrect visibilities for non-public posts
- Fix Home/Notifications tab switchers being cut off with smaller than default Dynamic Type sizes
- Fix posts using incorrect accent color for links in certain circumstances
- Fix not being able to remove followed hashtags from Explore screen
- Fix not being able to attach images from Markup share sheet or Shortcuts share action
- Fix very wide attachments being untappably short
- Fix double posting in poor network conditions
- Fix crash when autocompleting emoji on instances with a large number of custom emoji
- Akkoma: Fix not being able to follow hashtags
- Pleroma: Fix refreshing Mentions failing
- iPhone: Fix ducked Compose screen breaking when rotating on Plus/Max iPhone models
- iPhone: Fix Compose toolbar not extending to the full width of the screen in landscape on iPhone
- iPadOS: Fix closing app dismissing in-app Safari
- iPadOS: Fix reblog confirmation alert not being centered in split view
## 2023.5 (98) ## 2023.5 (98)
Bugfixes: Bugfixes:
- Fix broken animation when opening/closing expanded attachment view on Compose screen - Fix broken animation when opening/closing expanded attachment view on Compose screen

View File

@ -77,8 +77,7 @@ class PostService: ObservableObject {
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 ? draft.localOnly : nil, localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
idempotencyKey: draft.id.uuidString
) )
} }

View File

@ -49,9 +49,7 @@ class AttachmentRowController: ViewController {
private func removeAttachment() { private func removeAttachment() {
withAnimation { withAnimation {
var newAttachments = parent.draft.draftAttachments parent.draft.attachments.remove(attachment)
newAttachments.removeAll(where: { $0.id == attachment.id })
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
} }
} }
@ -72,8 +70,8 @@ class AttachmentRowController: ViewController {
private func recognizeText() { private func recognizeText() {
descriptionMode = .recognizingText descriptionMode = .recognizingText
DispatchQueue.global(qos: .userInitiated).async {
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
DispatchQueue.main.async {
let data: Data let data: Data
switch result { switch result {
case .success((let d, _)): case .success((let d, _)):

View File

@ -18,7 +18,19 @@ class AutocompleteEmojisController: ViewController {
@Published var expanded = false @Published var expanded = false
@Published var emojis: [Emoji] = [] @Published var emojis: [Emoji] = []
@Published var emojisBySection: [String: [Emoji]] = [:]
var emojisBySection: [String: [Emoji]] {
var values: [String: [Emoji]] = [:]
for emoji in emojis {
let key = emoji.category ?? ""
if !values.keys.contains(key) {
values[key] = [emoji]
} else {
values[key]!.append(emoji)
}
}
return values
}
init(composeController: ComposeController) { init(composeController: ComposeController) {
self.composeController = composeController self.composeController = composeController
@ -65,20 +77,11 @@ class AutocompleteEmojisController: ViewController {
var shortcodes = Set<String>() var shortcodes = Set<String>()
var newEmojis = [Emoji]() var newEmojis = [Emoji]()
var newEmojisBySection = [String: [Emoji]]()
for emoji in emojis where !shortcodes.contains(emoji.shortcode) { for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
newEmojis.append(emoji) newEmojis.append(emoji)
shortcodes.insert(emoji.shortcode) shortcodes.insert(emoji.shortcode)
let category = emoji.category ?? ""
if newEmojisBySection.keys.contains(category) {
newEmojisBySection[category]!.append(emoji)
} else {
newEmojisBySection[category] = [emoji]
}
} }
self.emojis = newEmojis self.emojis = newEmojis
self.emojisBySection = newEmojisBySection
} }
private func toggleExpanded() { private func toggleExpanded() {
@ -157,7 +160,7 @@ class AutocompleteEmojisController: ViewController {
private var horizontalScrollView: some View { private var horizontalScrollView: some View {
ScrollView(.horizontal) { ScrollView(.horizontal) {
LazyHStack(spacing: 8) { HStack(spacing: 8) {
ForEach(controller.emojis, id: \.shortcode) { emoji in ForEach(controller.emojis, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) { Button(action: { controller.autocomplete(with: emoji) }) {
HStack(spacing: 4) { HStack(spacing: 4) {
@ -171,6 +174,8 @@ class AutocompleteEmojisController: ViewController {
.frame(height: emojiSize) .frame(height: emojiSize)
} }
.animation(.linear(duration: 0.2), value: controller.emojis) .animation(.linear(duration: 0.2), value: controller.emojis)
Spacer(minLength: emojiSize)
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.frame(height: emojiSize + 16) .frame(height: emojiSize + 16)

View File

@ -100,10 +100,9 @@ class ToolbarController: ViewController {
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0) .scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
.frame(height: ToolbarController.height) .frame(height: ToolbarController.height)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing]) .background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
.overlay(alignment: .top) { .overlay(alignment: .top) {
Divider() Divider()
.edgesIgnoringSafeArea([.leading, .trailing])
} }
.background(GeometryReader { proxy in .background(GeometryReader { proxy in
Color.clear Color.clear

View File

@ -136,7 +136,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 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
@ -148,26 +147,14 @@ 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, jpegType, pngType, imageType, mp4Type, quickTimeType] [/*typeIdentifier, */gifType, jpegType, pngType, 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 type = UTType(typeIdentifier)!
// this seems to only occur when the item is a UIImage, rather than just image data,
// which seems to only occur when sharing a screenshot directly from the markup screen
if type == .image,
let image = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIImage.self, from: data),
let pngData = image.pngData() {
data = pngData
type = .png
}
let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil) let attachment = DraftAttachment(entity: DraftsPersistentContainer.shared.persistentStoreCoordinator.managedObjectModel.entitiesByName["DraftAttachment"]!, insertInto: nil)
attachment.id = UUID() attachment.id = UUID()
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type) attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
attachment.fileType = type.identifier attachment.fileType = typeIdentifier
attachment.attachmentDescription = "" attachment.attachmentDescription = ""
return attachment return attachment
} }

View File

@ -22,11 +22,10 @@ struct LanguagePicker: View {
} }
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? { static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
guard let bcp47Lang = mode.primaryLanguage, guard let bcp47Lang = mode.primaryLanguage else {
!bcp47Lang.isEmpty else {
return nil return nil
} }
var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: min(3, bcp47Lang.count))] var maybeIso639Code = bcp47Lang[..<bcp47Lang.index(bcp47Lang.startIndex, offsetBy: 3)]
if maybeIso639Code.last == "-" { if maybeIso639Code.last == "-" {
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)] maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
} }

View File

@ -8,6 +8,8 @@
import UIKit import UIKit
public protocol DuckableViewController: UIViewController { public protocol DuckableViewController: UIViewController {
var duckableDelegate: DuckableViewControllerDelegate? { get set }
func duckableViewControllerShouldDuck() -> DuckAttemptAction func duckableViewControllerShouldDuck() -> DuckAttemptAction
func duckableViewControllerMayAttemptToDuck() func duckableViewControllerMayAttemptToDuck()
@ -24,6 +26,10 @@ extension DuckableViewController {
public func duckableViewControllerDidFinishAnimatingDuck() {} public func duckableViewControllerDidFinishAnimatingDuck() {}
} }
public protocol DuckableViewControllerDelegate: AnyObject {
func duckableViewControllerWillDismiss(animated: Bool)
}
public enum DuckAttemptAction { public enum DuckAttemptAction {
case duck case duck
case dismiss case dismiss

View File

@ -11,7 +11,7 @@ let duckedCornerRadius: CGFloat = 10
let detentHeight: CGFloat = 44 let detentHeight: CGFloat = 44
@available(iOS 16.0, *) @available(iOS 16.0, *)
public class DuckableContainerViewController: UIViewController { public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
public let child: UIViewController public let child: UIViewController
private var bottomConstraint: NSLayoutConstraint! private var bottomConstraint: NSLayoutConstraint!
@ -87,6 +87,7 @@ public class DuckableContainerViewController: UIViewController {
} }
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) { private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
viewController.duckableDelegate = self
viewController.modalPresentationStyle = .custom viewController.modalPresentationStyle = .custom
viewController.transitioningDelegate = self viewController.transitioningDelegate = self
present(viewController, animated: animated) { present(viewController, animated: animated) {
@ -95,10 +96,7 @@ public class DuckableContainerViewController: UIViewController {
} }
} }
func dismissalTransitionWillBegin() { public func duckableViewControllerWillDismiss(animated: Bool) {
guard case .presentingDucked(_, _) = state else {
return
}
state = .idle state = .idle
bottomConstraint.isActive = false bottomConstraint.isActive = false
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
@ -146,7 +144,7 @@ public class DuckableContainerViewController: UIViewController {
case .block: case .block:
viewController.sheetPresentationController!.selectedDetentIdentifier = .large viewController.sheetPresentationController!.selectedDetentIdentifier = .large
case .dismiss: case .dismiss:
// duckableViewControllerWillDismiss() duckableViewControllerWillDismiss(animated: true)
dismiss(animated: true) dismiss(animated: true)
} }
} }
@ -191,7 +189,7 @@ public class DuckableContainerViewController: UIViewController {
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate { extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? { public func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
let controller = DuckableSheetPresentationController(presentedViewController: presented, presenting: presenting) let controller = UISheetPresentationController(presentedViewController: presented, presenting: presenting)
controller.delegate = self controller.delegate = self
controller.prefersGrabberVisible = true controller.prefersGrabberVisible = true
controller.selectedDetentIdentifier = .large controller.selectedDetentIdentifier = .large
@ -217,14 +215,6 @@ extension DuckableContainerViewController: UIViewControllerTransitioningDelegate
} }
} }
@available(iOS 16.0, *)
class DuckableSheetPresentationController: UISheetPresentationController {
override func dismissalTransitionWillBegin() {
super.dismissalTransitionWillBegin()
(self.delegate as! DuckableContainerViewController).dismissalTransitionWillBegin()
}
}
@available(iOS 16.0, *) @available(iOS 16.0, *)
extension DuckableContainerViewController: UISheetPresentationControllerDelegate { extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) { public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {

View File

@ -72,7 +72,7 @@ public class InstanceFeatures: ObservableObject {
public var probablySupportsMarkdown: Bool { public var probablySupportsMarkdown: Bool {
switch instanceType { switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_): case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .calckey(_):
return true return true
default: default:
return false return false
@ -96,13 +96,7 @@ public class InstanceFeatures: ObservableObject {
} }
public var canFollowHashtags: Bool { public var canFollowHashtags: Bool {
if case .mastodon(_, let version) = instanceType { hasMastodonVersion(4, 0, 0)
return version >= Version(4, 0, 0)
} else if case .pleroma(.akkoma(let version)) = instanceType {
return version >= Version(3, 4, 0)
} else {
return false
}
} }
public var filtersV2: Bool { public var filtersV2: Bool {
@ -134,16 +128,6 @@ public class InstanceFeatures: ObservableObject {
} }
} }
public var statusEditNotifications: Bool {
// pleroma doesn't seem to support 'update' type notifications, even though it supports edits
hasMastodonVersion(3, 5, 0)
}
public var statusNotifications: Bool {
// pleroma doesn't support notifications for new posts from an account
hasMastodonVersion(3, 3, 0)
}
public var needsEditAttachmentsInSeparateRequest: Bool { public var needsEditAttachmentsInSeparateRequest: Bool {
instanceType.isPleroma(.akkoma(nil)) instanceType.isPleroma(.akkoma(nil))
} }
@ -151,7 +135,7 @@ public class InstanceFeatures: ObservableObject {
public init() { public init() {
} }
public func update(instance: InstanceInfo, nodeInfo: NodeInfo?) { public func update(instance: Instance, nodeInfo: NodeInfo?) {
let ver = instance.version.lowercased() let ver = instance.version.lowercased()
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo // check glitch first b/c it still reports "mastodon" as the software in nodeinfo
if ver.contains("glitch") { if ver.contains("glitch") {
@ -195,8 +179,8 @@ public class InstanceFeatures: ObservableObject {
instanceType = .pixelfed instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" { } else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial instanceType = .gotosocial
} else if ver.contains("firefish") || ver.contains("calckey") { } else if ver.contains("calckey") {
instanceType = .firefish(nodeInfo?.software.version) instanceType = .calckey(nodeInfo?.software.version)
} else { } else {
instanceType = .mastodon(.vanilla, Version(string: ver)) instanceType = .mastodon(.vanilla, Version(string: ver))
} }
@ -235,7 +219,7 @@ extension InstanceFeatures {
case pleroma(PleromaType) case pleroma(PleromaType)
case pixelfed case pixelfed
case gotosocial case gotosocial
case firefish(String?) case calckey(String?)
var isMastodon: Bool { var isMastodon: Bool {
if case .mastodon(_, _) = self { if case .mastodon(_, _) = self {

View File

@ -1,34 +0,0 @@
//
// InstanceInfo.swift
// InstanceFeatures
//
// Created by Shadowfacts on 5/28/23.
//
import Foundation
import Pachyderm
public struct InstanceInfo {
public let version: String
public let maxStatusCharacters: Int?
public let configuration: Instance.Configuration?
public let pollsConfiguration: Instance.PollsConfiguration?
public init(version: String, maxStatusCharacters: Int?, configuration: Instance.Configuration?, pollsConfiguration: Instance.PollsConfiguration?) {
self.version = version
self.maxStatusCharacters = maxStatusCharacters
self.configuration = configuration
self.pollsConfiguration = pollsConfiguration
}
}
extension InstanceInfo {
public init(instance: Instance) {
self.init(
version: instance.version,
maxStatusCharacters: instance.maxStatusCharacters,
configuration: instance.configuration,
pollsConfiguration: instance.pollsConfiguration
)
}
}

View File

@ -113,9 +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
for (name, value) in request.headers {
urlRequest.setValue(value, forHTTPHeaderField: name)
}
if let mimeType = request.body.mimeType { if let mimeType = request.body.mimeType {
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type") urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
} }
@ -400,9 +397,8 @@ public class Client {
pollOptions: [String]? = nil, pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil, pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil, pollMultiple: Bool? = nil,
localOnly: Bool? = nil, /* hometown only, not glitch */ localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
idempotencyKey: String) -> Request<Status> { return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text, "status" => text,
"content_type" => contentType.mimeType, "content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo, "in_reply_to_id" => inReplyTo,
@ -414,8 +410,6 @@ public class Client {
"poll[multiple]" => pollMultiple, "poll[multiple]" => pollMultiple,
"local_only" => localOnly, "local_only" => localOnly,
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions)) ] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
req.headers["Idempotency-Key"] = idempotencyKey
return req
} }
public static func editStatus( public static func editStatus(

View File

@ -107,7 +107,7 @@ extension Instance {
} }
extension Instance { extension Instance {
public struct Configuration: Codable, Sendable { public struct Configuration: Decodable, Sendable {
public let statuses: StatusesConfiguration public let statuses: StatusesConfiguration
public let mediaAttachments: MediaAttachmentsConfiguration public let mediaAttachments: MediaAttachmentsConfiguration
/// Use Instance.pollsConfiguration to support older instance that don't have this nested /// Use Instance.pollsConfiguration to support older instance that don't have this nested
@ -122,7 +122,7 @@ extension Instance {
} }
extension Instance { extension Instance {
public struct StatusesConfiguration: Codable, Sendable { public struct StatusesConfiguration: Decodable, Sendable {
public let maxCharacters: Int public let maxCharacters: Int
public let maxMediaAttachments: Int public let maxMediaAttachments: Int
public let charactersReservedPerURL: Int public let charactersReservedPerURL: Int
@ -136,7 +136,7 @@ extension Instance {
} }
extension Instance { extension Instance {
public struct MediaAttachmentsConfiguration: Codable, Sendable { public struct MediaAttachmentsConfiguration: Decodable, Sendable {
public let supportedMIMETypes: [String] public let supportedMIMETypes: [String]
public let imageSizeLimit: Int public let imageSizeLimit: Int
public let imageMatrixLimit: Int public let imageMatrixLimit: Int
@ -156,7 +156,7 @@ extension Instance {
} }
extension Instance { extension Instance {
public struct PollsConfiguration: Codable, Sendable { public struct PollsConfiguration: Decodable, Sendable {
public let maxOptions: Int public let maxOptions: Int
public let maxCharactersPerOption: Int public let maxCharactersPerOption: Int
public let minExpiration: TimeInterval public let minExpiration: TimeInterval

View File

@ -8,11 +8,11 @@
import Foundation import Foundation
public struct NodeInfo: Decodable, Sendable, Equatable { public struct NodeInfo: Decodable, Sendable {
public let version: String public let version: String
public let software: Software public let software: Software
public struct Software: Decodable, Sendable, Equatable { public struct Software: Decodable, Sendable {
public let name: String public let name: String
public let version: String public let version: String
} }

View File

@ -13,7 +13,6 @@ public struct Request<ResultType: Decodable>: Sendable {
let endpoint: Endpoint let endpoint: Endpoint
let body: Body let body: Body
var queryParameters: [Parameter] var queryParameters: [Parameter]
var headers: [String: String] = [:]
var additionalAcceptableHTTPCodes: [Int] = [] var additionalAcceptableHTTPCodes: [Int] = []
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) { init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {

View File

@ -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(instance: instance), nodeInfo: await nodeInfo) self.instanceFeatures.update(instance: instance, nodeInfo: await nodeInfo)
} }
Task { @MainActor in Task { @MainActor in

View File

@ -79,14 +79,7 @@ class ShareViewController: UIViewController {
var attachments: [DraftAttachment] = [] var attachments: [DraftAttachment] = []
for itemProvider in inputItem.attachments ?? [] { for itemProvider in inputItem.attachments ?? [] {
// attachments have the highest priority, but only given this heuristic if let attached: NSURL = await getObject(from: itemProvider) {
// otherwise attachment decoding ends up being overzealous
let likelyAttachment = [UTType.image, .movie].contains(where: { itemProvider.hasItemConformingToTypeIdentifier($0.identifier) })
if likelyAttachment,
let attachment: DraftAttachment = await getObject(from: itemProvider) {
attachments.append(attachment)
} else if let attached: NSURL = await getObject(from: itemProvider) {
if url == nil { if url == nil {
url = attached as URL url = attached as URL
} }
@ -94,6 +87,8 @@ class ShareViewController: UIViewController {
if text.isEmpty { if text.isEmpty {
text = s as String text = s as String
} }
} else if let attachment: DraftAttachment = await getObject(from: itemProvider) {
attachments.append(attachment)
} }
} }

View File

@ -29,7 +29,6 @@
D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */; }; D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */; };
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */; }; D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */; };
D601FA84297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */; }; D601FA84297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */; };
D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608470E2A245D1F00C17380 /* ActiveInstance.swift */; };
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; }; D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */; };
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; }; D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; }; D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = D60CFFDA24A290BA00D00083 /* SwiftSoup */; };
@ -427,7 +426,6 @@
D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusCollectionViewCell.swift; sourceTree = "<group>"; }; D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMainStatusCollectionViewCell.swift; sourceTree = "<group>"; };
D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfileCardCollectionViewCell.swift; sourceTree = "<group>"; }; D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestedProfileCardCollectionViewCell.swift; sourceTree = "<group>"; };
D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SuggestedProfileCardCollectionViewCell.xib; sourceTree = "<group>"; }; D601FA82297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SuggestedProfileCardCollectionViewCell.xib; sourceTree = "<group>"; };
D608470E2A245D1F00C17380 /* ActiveInstance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveInstance.swift; sourceTree = "<group>"; };
D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; }; D6093F9A25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagSearchResultsViewController.swift; sourceTree = "<group>"; };
D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; }; D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendHistoryView.swift; sourceTree = "<group>"; };
D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; }; D60E2F232442372B005F8713 /* StatusMO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusMO.swift; sourceTree = "<group>"; };
@ -956,7 +954,6 @@
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */, D6A3A3812956123A0036B6EF /* TimelinePosition.swift */,
D68A76E229524D2A001DA1B3 /* ListMO.swift */, D68A76E229524D2A001DA1B3 /* ListMO.swift */,
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */, D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */,
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
); );
@ -1962,7 +1959,6 @@
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */, D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */, D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */,
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */, D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */,
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 */,
@ -2390,7 +2386,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2398,7 +2394,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
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)";
@ -2456,7 +2452,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2465,7 +2461,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2482,7 +2478,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2493,7 +2489,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2511,7 +2507,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2522,7 +2518,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2540,7 +2536,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2551,7 +2547,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2695,7 +2691,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2703,7 +2699,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
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";
@ -2726,7 +2722,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2734,7 +2730,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
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)";
@ -2832,7 +2828,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2841,7 +2837,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@ -2858,7 +2854,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 = 100; CURRENT_PROJECT_VERSION = 98;
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;
@ -2867,7 +2863,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 2023.6; MARKETING_VERSION = 2023.5;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker"; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;

View File

@ -23,12 +23,15 @@ class MastodonController: ObservableObject {
@available(*, message: "do something less dumb") @available(*, message: "do something less dumb")
static var first: MastodonController { all.first!.value } static var first: MastodonController { all.first!.value }
@MainActor
static func getForAccount(_ account: UserAccountInfo) -> MastodonController { static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
if let controller = all[account] { if let controller = all[account] {
return controller return controller
} else { } else {
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account) let controller = MastodonController(instanceURL: account.instanceURL)
controller.accountInfo = account
controller.client.clientID = account.clientID
controller.client.clientSecret = account.clientSecret
controller.client.accessToken = account.accessToken
all[account] = controller all[account] = controller
return controller return controller
} }
@ -43,7 +46,7 @@ class MastodonController: ObservableObject {
} }
private let transient: Bool private let transient: Bool
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient) private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL let instanceURL: URL
var accountInfo: UserAccountInfo? var accountInfo: UserAccountInfo?
@ -52,9 +55,8 @@ class MastodonController: ObservableObject {
let client: Client! let client: Client!
let instanceFeatures = InstanceFeatures() let instanceFeatures = InstanceFeatures()
@Published private(set) var account: AccountMO? @Published private(set) var account: Account!
@Published private(set) var instance: Instance? @Published private(set) var instance: Instance!
@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] = []
@Published private(set) var customEmojis: [Emoji]? @Published private(set) var customEmojis: [Emoji]?
@ -70,46 +72,24 @@ class MastodonController: ObservableObject {
accountInfo != nil accountInfo != nil
} }
// main-actor b/c fetchActiveAccountID and fetchActiveInstance use the viewContext init(instanceURL: URL, transient: Bool = false) {
@MainActor
init(instanceURL: URL, accountInfo: UserAccountInfo?) {
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.accountInfo = accountInfo self.accountInfo = nil
self.client = Client(baseURL: instanceURL, session: .appDefault) self.client = Client(baseURL: instanceURL, session: .appDefault)
self.transient = accountInfo == nil self.transient = transient
self.persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
self.client.clientID = accountInfo?.clientID
self.client.clientSecret = accountInfo?.clientSecret
self.client.accessToken = accountInfo?.accessToken
if !transient {
fetchActiveAccount()
fetchActiveInstance()
}
$instanceInfo
.compactMap { $0 }
.combineLatest($nodeInfo)
.sink { [unowned self] (instance, nodeInfo) in
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
$instanceInfo
.compactMap { $0 }
.removeDuplicates(by: { $0.version == $1.version })
.combineLatest($nodeInfo.removeDuplicates())
.sink { (instance, nodeInfo) in
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
$instance $instance
.compactMap { $0 } .combineLatest($nodeInfo)
.sink { [unowned self] in .compactMap { (instance, nodeInfo) in
self.updateActiveInstance(from: $0) if let instance {
self.instanceInfo = InstanceInfo(instance: $0) return (instance, nodeInfo)
} else {
return nil
}
}
.sink { [unowned self] (instance, nodeInfo) in
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
} }
.store(in: &cancellables) .store(in: &cancellables)
@ -123,12 +103,6 @@ class MastodonController: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
@MainActor
convenience init(instanceURL: URL, transient: Bool) {
precondition(transient, "account info must be provided if transient is false")
self.init(instanceURL: instanceURL, accountInfo: nil)
}
@discardableResult @discardableResult
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? { func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? {
return client.run(request, completion: completion) return client.run(request, completion: completion)
@ -233,8 +207,8 @@ class MastodonController: ObservableObject {
} }
} }
func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) { func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
if let account { if account != nil {
completion?(.success(account)) completion?(.success(account))
} else { } else {
let request = Client.getSelfAccount() let request = Client.getSelfAccount()
@ -244,25 +218,25 @@ class MastodonController: ObservableObject {
completion?(.failure(error)) completion?(.failure(error))
case let .success(account, _): case let .success(account, _):
let context = self.persistentContainer.viewContext DispatchQueue.main.async {
context.perform { self.account = account
let accountMO: AccountMO
if let existing = self.persistentContainer.account(for: account.id, in: context) {
accountMO = existing
existing.updateFrom(apiAccount: account, container: self.persistentContainer)
} else {
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
} }
accountMO.active = true self.persistentContainer.backgroundContext.perform {
self.account = accountMO if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
completion?(.success(accountMO)) accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
} else {
// the first time the user's account is added to the store,
// increment its reference count so that it's never removed
self.persistentContainer.addOrUpdate(account: account)
}
completion?(.success(account))
} }
} }
} }
} }
} }
func getOwnAccount() async throws -> AccountMO { func getOwnAccount() async throws -> Account {
if let account = account { if let account = account {
return account return account
} else { } else {
@ -358,37 +332,6 @@ class MastodonController: ObservableObject {
} }
} }
private func updateActiveInstance(from instance: Instance) {
persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: instance)
} else {
let new = ActiveInstance(context: context)
new.update(from: instance)
}
if context.hasChanges {
try? context.save()
}
}
}
@MainActor
private func fetchActiveAccount() {
let req = AccountMO.fetchRequest()
req.predicate = NSPredicate(format: "active = YES")
if let activeAccount = try? persistentContainer.viewContext.fetch(req).first {
account = activeAccount
}
}
@MainActor
private func fetchActiveInstance() {
if let activeInstance = try? persistentContainer.viewContext.fetch(ActiveInstance.fetchRequest()).first {
let info = InstanceInfo(activeInstance: activeInstance)
self.instanceInfo = info
}
}
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) {
if let emojis = self.customEmojis { if let emojis = self.customEmojis {
completion(emojis) completion(emojis)
@ -579,7 +522,7 @@ class MastodonController: ObservableObject {
} }
private func setInstanceBreadcrumb(instance: InstanceInfo, nodeInfo: NodeInfo?) { private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
let crumb = Breadcrumb(level: .info, category: "MastodonController") let crumb = Breadcrumb(level: .info, category: "MastodonController")
crumb.data = [ crumb.data = [
"instance": [ "instance": [

View File

@ -17,6 +17,7 @@ class ReblogService {
private let status: StatusMO private let status: StatusMO
var hapticFeedback = true var hapticFeedback = true
var visibility: Visibility? = nil
var requireConfirmation = Preferences.shared.confirmBeforeReblog var requireConfirmation = Preferences.shared.confirmBeforeReblog
init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) { init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) {
@ -30,30 +31,26 @@ class ReblogService {
requireConfirmation { requireConfirmation {
presentConfirmationAlert() presentConfirmationAlert()
} else { } else {
await doToggleReblog(visibility: nil) await doToggleReblog()
} }
} }
private func presentConfirmationAlert() { private func presentConfirmationAlert() {
let image: UIImage? let image: UIImage?
let reblogVisibilityActions: [CustomAlertController.MenuAction] let reblogVisibilityActions: [CustomAlertController.MenuAction]?
let maximumVisibility = status.visibility
if mastodonController.instanceFeatures.reblogVisibility { if mastodonController.instanceFeatures.reblogVisibility {
image = UIImage(systemName: maximumVisibility.unfilledImageName) image = UIImage(systemName: Visibility.public.unfilledImageName)
reblogVisibilityActions = [Visibility.unlisted, .private].compactMap { visibility in reblogVisibilityActions = [Visibility.unlisted, .private].map { visibility in
guard visibility < maximumVisibility else { CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) {
return nil
}
return CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) {
// deliberately retain a strong reference to self // deliberately retain a strong reference to self
Task { Task {
await self.doToggleReblog(visibility: visibility) await self.doToggleReblog()
} }
} }
} }
} else { } else {
image = nil image = nil
reblogVisibilityActions = [] reblogVisibilityActions = nil
} }
let preview = ConfirmReblogStatusPreviewView(status: status) let preview = ConfirmReblogStatusPreviewView(status: status)
@ -62,11 +59,11 @@ class ReblogService {
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: { CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: {
// deliberately retain a strong reference to self // deliberately retain a strong reference to self
Task { Task {
await self.doToggleReblog(visibility: nil) await self.doToggleReblog()
} }
}) })
]) ])
if !reblogVisibilityActions.isEmpty { if let reblogVisibilityActions {
var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil) var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil)
menuAction.isSecondaryMenu = true menuAction.isSecondaryMenu = true
config.actions.append(menuAction) config.actions.append(menuAction)
@ -75,7 +72,7 @@ class ReblogService {
presenter.present(alert, animated: true) presenter.present(alert, animated: true)
} }
private func doToggleReblog(visibility: Visibility?) async { private func doToggleReblog() async {
let oldValue = status.reblogged let oldValue = status.reblogged
status.reblogged.toggle() status.reblogged.toggle()
mastodonController.persistentContainer.statusSubject.send(status.id) mastodonController.persistentContainer.statusSubject.send(status.id)

View File

@ -22,9 +22,7 @@ class ToggleFollowHashtagService {
self.presenter = presenter self.presenter = presenter
} }
@discardableResult func toggleFollow() async {
func toggleFollow() async -> Bool {
let success: Bool
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
var config: ToastConfiguration var config: ToastConfiguration
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtagName }) { if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtagName }) {
@ -38,14 +36,11 @@ class ToggleFollowHashtagService {
config = ToastConfiguration(title: "Unfollowed Hashtag") config = ToastConfiguration(title: "Unfollowed Hashtag")
config.systemImageName = "checkmark" config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 2
success = true
} catch { } catch {
config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { toast in config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self.toggleFollow() await self.toggleFollow()
} }
success = false
} }
} else { } else {
do { do {
@ -58,19 +53,15 @@ class ToggleFollowHashtagService {
config = ToastConfiguration(title: "Followed Hashtag") config = ToastConfiguration(title: "Followed Hashtag")
config.systemImageName = "checkmark" config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2 config.dismissAutomaticallyAfter = 2
success = true
} catch { } catch {
config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { toast in config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self.toggleFollow() await self.toggleFollow()
} }
success = false
} }
} }
presenter.showToast(configuration: config, animated: true) presenter.showToast(configuration: config, animated: true)
mastodonController.persistentContainer.save(context: context) mastodonController.persistentContainer.save(context: context)
return success
} }
} }

View File

@ -115,8 +115,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
if let clazz = NSClassFromString("SentryInstallation"), if let clazz = NSClassFromString("SentryInstallation"),
let objClazz = clazz as AnyObject as? NSObject, let objClazz = clazz as AnyObject as? NSObjectProtocol,
let id = objClazz.value(forKey: "id") as? String { objClazz.responds(to: Selector(("id"))),
let id = objClazz.perform(Selector(("id"))).takeUnretainedValue() as? String {
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)") logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
} }
} }

View File

@ -25,8 +25,6 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
} }
@NSManaged public var acct: String @NSManaged public var acct: String
/// Whether this AccountMO is the active (logged-in) account.
@NSManaged public var active: Bool
@NSManaged public var avatar: URL? @NSManaged public var avatar: URL?
@NSManaged public var botCD: Bool @NSManaged public var botCD: Bool
@NSManaged public var createdAt: Date @NSManaged public var createdAt: Date

View File

@ -1,49 +0,0 @@
//
// ActiveInstance.swift
// Tusker
//
// Created by Shadowfacts on 5/28/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import Pachyderm
import InstanceFeatures
@objc(ActiveInstance)
public final class ActiveInstance: NSManagedObject {
@nonobjc class public func fetchRequest() -> NSFetchRequest<ActiveInstance> {
return NSFetchRequest(entityName: "ActiveInstance")
}
@NSManaged public var version: String
@NSManaged public var maxStatusCharacters: Int
@NSManaged private var configurationData: Data?
@NSManaged private var pollsConfigurationData: Data?
@LazilyDecoding(from: \ActiveInstance.configurationData, fallback: nil)
public var configuration: Instance.Configuration?
@LazilyDecoding(from: \ActiveInstance.pollsConfigurationData, fallback: nil)
public var pollsConfiguration: Instance.PollsConfiguration?
func update(from instance: Instance) {
self.version = instance.version
self.maxStatusCharacters = instance.maxStatusCharacters ?? 500
self.configuration = instance.configuration
self.pollsConfiguration = instance.pollsConfiguration
}
}
extension InstanceInfo {
init(activeInstance: ActiveInstance) {
self.init(
version: activeInstance.version,
maxStatusCharacters: activeInstance.maxStatusCharacters,
configuration: activeInstance.configuration,
pollsConfiguration: activeInstance.pollsConfiguration
)
}
}

View File

@ -311,14 +311,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
} }
/// The caller is responsible for calling this on a queue appropriate for `context`.
func addOrUpdateSynchronously(account: Account, in context: NSManagedObjectContext) -> AccountMO {
let accountMO = self.upsert(account: account, in: context)
self.save(context: context)
self.accountSubject.send(account.id)
return accountMO
}
func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? { func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? {
let context = context ?? viewContext let context = context ?? viewContext
let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest() let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest()

View File

@ -2,7 +2,6 @@
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" 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="avatar" optional="YES" attributeType="URI"/> <attribute name="avatar" optional="YES" attributeType="URI"/>
<attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/> <attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -34,12 +33,6 @@
<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"/>
</entity> </entity>
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
<attribute name="configurationData" optional="YES" attributeType="Binary"/>
<attribute name="maxStatusCharacters" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="pollsConfigurationData" optional="YES" attributeType="Binary"/>
<attribute name="version" optional="YES" attributeType="String"/>
</entity>
<entity name="Filter" representedClassName="FilterMO" syncable="YES"> <entity name="Filter" representedClassName="FilterMO" syncable="YES">
<attribute name="action" attributeType="String" defaultValueString="warn"/> <attribute name="action" attributeType="String" defaultValueString="warn"/>
<attribute name="context" attributeType="String"/> <attribute name="context" attributeType="String"/>
@ -145,6 +138,7 @@
<memberEntity name="TimelinePosition"/> <memberEntity name="TimelinePosition"/>
</configuration> </configuration>
<configuration name="Local"> <configuration name="Local">
<memberEntity name="Account"/>
<memberEntity name="Filter"/> <memberEntity name="Filter"/>
<memberEntity name="FilterKeyword"/> <memberEntity name="FilterKeyword"/>
<memberEntity name="FollowedHashtag"/> <memberEntity name="FollowedHashtag"/>
@ -152,7 +146,5 @@
<memberEntity name="Status"/> <memberEntity name="Status"/>
<memberEntity name="TimelineState"/> <memberEntity name="TimelineState"/>
<memberEntity name="List"/> <memberEntity name="List"/>
<memberEntity name="Account"/>
<memberEntity name="ActiveInstance"/>
</configuration> </configuration>
</model> </model>

View File

@ -23,12 +23,11 @@ protocol ComposeHostingControllerDelegate: AnyObject {
class ComposeHostingController: UIHostingController<ComposeHostingController.View>, DuckableViewController { class ComposeHostingController: UIHostingController<ComposeHostingController.View>, DuckableViewController {
weak var delegate: ComposeHostingControllerDelegate? weak var delegate: ComposeHostingControllerDelegate?
weak var duckableDelegate: DuckableViewControllerDelegate?
let controller: ComposeController let controller: ComposeController
let mastodonController: MastodonController let mastodonController: MastodonController
private var cancellables = Set<AnyCancellable>()
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)? private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
private var drawingCompletion: ((PKDrawing) -> Void)? private var drawingCompletion: ((PKDrawing) -> Void)?
@ -46,6 +45,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) }, replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) } emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
) )
controller.currentAccount = mastodonController.account
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -59,12 +59,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
// set an initial title immediately, in case we're starting ducked // set an initial title immediately, in case we're starting ducked
self.navigationItem.title = self.controller.navigationTitle self.navigationItem.title = self.controller.navigationTitle
mastodonController.$account
.sink { [unowned self] in
self.controller.currentAccount = $0
}
.store(in: &cancellables)
} }
required init?(coder aDecoder: NSCoder) { required init?(coder aDecoder: NSCoder) {
@ -113,6 +107,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
return return
} else { } else {
dismiss(animated: true) dismiss(animated: true)
duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
} }
} }

View File

@ -147,8 +147,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true) Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
} }
snapshot.appendItems(parentItems, toSection: .ancestors) snapshot.appendItems(parentItems, toSection: .ancestors)
// don't need to reconfigure main item, since when the refreshed copy was loaded snapshot.reconfigureItems([mainStatusItem])
// it would have triggered a reconfigure via the status observer
// convert sub-threads into items for section and add to snapshot // convert sub-threads into items for section and add to snapshot
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot) self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)

View File

@ -57,6 +57,10 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
dataSource = createDataSource() dataSource = createDataSource()
applyInitialSnapshot() applyInitialSnapshot()
if mastodonController.instance == nil {
mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
}
resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController! resultsController.exploreNavigationController = self.navigationController!
searchController = UISearchController(searchResultsController: resultsController) searchController = UISearchController(searchResultsController: resultsController)
@ -75,9 +79,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(savedInstancesChanged), name: .savedInstancesChanged, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
mastodonController.instanceFeatures.featuresUpdated
.sink { [unowned self] in self.instanceFeaturesChanged() }
.store(in: &cancellables)
mastodonController.$lists mastodonController.$lists
.sink { [unowned self] in self.reloadLists($0) } .sink { [unowned self] in self.reloadLists($0) }
.store(in: &cancellables) .store(in: &cancellables)
@ -193,7 +194,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
snapshot.appendItems([.trends], toSection: .discover) snapshot.appendItems([.trends], toSection: .discover)
} }
private func instanceFeaturesChanged() { private func ownInstanceLoaded(_ instance: Instance) {
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
if mastodonController.instanceFeatures.trends, if mastodonController.instanceFeatures.trends,
!snapshot.sectionIdentifiers.contains(.discover) { !snapshot.sectionIdentifiers.contains(.discover) {
@ -288,6 +289,15 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
} }
} }
func removeSavedHashtag(_ hashtag: Hashtag) {
let context = mastodonController.persistentContainer.viewContext
let req = SavedHashtag.fetchRequest(name: hashtag.name, account: mastodonController.accountInfo!)
if let hashtag = try? context.fetch(req).first {
context.delete(hashtag)
try! context.save()
}
}
func removeSavedInstance(_ instanceURL: URL) { func removeSavedInstance(_ instanceURL: URL) {
let context = mastodonController.persistentContainer.viewContext let context = mastodonController.persistentContainer.viewContext
let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!) let req = SavedInstance.fetchRequest(url: instanceURL, account: mastodonController.accountInfo!)
@ -298,45 +308,36 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
} }
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? { private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {
var actions = [UIContextualAction]() let title: String
let handler: UIContextualAction.Handler
switch dataSource.itemIdentifier(for: indexPath) { switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list): case let .list(list):
actions.append(UIContextualAction(style: .destructive, title: "Delete", handler: { _, _, completion in title = NSLocalizedString("Delete", comment: "delete swipe action title")
handler = { (_, _, completion) in
self.deleteList(list, completion: completion) self.deleteList(list, completion: completion)
})) }
case let .savedHashtag(hashtag): case let .savedHashtag(hashtag):
let name = hashtag.name.lowercased() title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
let context = mastodonController.persistentContainer.viewContext handler = { (_, _, completion) in
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first self.removeSavedHashtag(hashtag)
if let existing { completion(true)
actions.append(UIContextualAction(style: .destructive, title: "Unsave", handler: { _, _, completion in
context.delete(existing)
try! context.save()
}))
}
if mastodonController.instanceFeatures.canFollowHashtags,
mastodonController.followedHashtags.contains(where: { $0.name.lowercased() == name }) {
actions.append(UIContextualAction(style: .destructive, title: "Unfollow", handler: { _, _, completion in
Task {
let success =
await ToggleFollowHashtagService(hashtagName: hashtag.name, presenter: self)
.toggleFollow()
completion(success)
}
}))
} }
case let .savedInstance(url): case let .savedInstance(url):
actions.append(UIContextualAction(style: .destructive, title: "Unsave", handler: { _, _, completion in title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in
self.removeSavedInstance(url) self.removeSavedInstance(url)
completion(true) completion(true)
})) }
default: default:
return nil return nil
} }
return UISwipeActionsConfiguration(actions: actions)
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: title, handler: handler)
])
} }
// MARK: - Collection View Delegate // MARK: - Collection View Delegate
@ -581,7 +582,3 @@ extension ExploreViewController: UICollectionViewDragDelegate {
return [UIDragItem(itemProvider: provider)] return [UIDragItem(itemProvider: provider)]
} }
} }
extension ExploreViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}

View File

@ -97,6 +97,10 @@ class MainSidebarViewController: UIViewController {
applyInitialSnapshot() applyInitialSnapshot()
if mastodonController.instance == nil {
mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
}
select(item: .tab(.timelines), animated: false) select(item: .tab(.timelines), animated: false)
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
@ -185,6 +189,14 @@ class MainSidebarViewController: UIViewController {
reloadSavedInstances() reloadSavedInstances()
} }
private func ownInstanceLoaded(_ instance: Instance) {
let prevSelected = collectionView.indexPathsForSelectedItems
if let prevSelected = prevSelected?.first {
collectionView.selectItem(at: prevSelected, animated: false, scrollPosition: .top)
}
}
private func reloadLists(_ lists: [List], animated: Bool) { private func reloadLists(_ lists: [List], animated: Bool) {
if let selectedItem, if let selectedItem,
case .list(let list) = selectedItem, case .list(let list) = selectedItem,

View File

@ -225,7 +225,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case let .tab(tab): case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly // sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab, dismissPresented: false) tabBarViewController.select(tab: tab)
case .explore: case .explore:
// Search sidebar item maps to the Explore tab with the search controller/results visible // Search sidebar item maps to the Explore tab with the search controller/results visible
@ -247,7 +247,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
explore.loadViewIfNeeded() explore.loadViewIfNeeded()
let search = secondaryNavController.viewControllers.first as! InlineTrendsViewController let search = secondaryNavController.viewControllers.first as! InlineTrendsViewController
if search.searchController?.isActive == true { if search.searchController.isActive {
// Copy the search query from the search VC to the Explore VC's search controller. // Copy the search query from the search VC to the Explore VC's search controller.
let query = search.searchController.searchBar.text ?? "" let query = search.searchController.searchBar.text ?? ""
explore.searchController.searchBar.text = query explore.searchController.searchBar.text = query
@ -268,10 +268,10 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened // Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true) transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true)
tabBarViewController.select(tab: .explore, dismissPresented: false) tabBarViewController.select(tab: .explore)
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_): case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore, dismissPresented: false) tabBarViewController.select(tab: .explore)
// Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously // Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously
// in compact mode and performing a search. // in compact mode and performing a search.
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController

View File

@ -111,13 +111,13 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
repositionFastSwitcherIndicator() repositionFastSwitcherIndicator()
} }
func select(tab: Tab, dismissPresented: Bool) { func select(tab: Tab) {
if tab == .compose { if tab == .compose {
compose(editing: nil) compose(editing: nil)
} else { } else {
// when switching tabs, dismiss the currently presented VC // when switching tabs, dismiss the currently presented VC
// otherwise the selected tab changes behind the presented VC // otherwise the selected tab changes behind the presented VC
if presentedViewController != nil && dismissPresented { if presentedViewController != nil {
dismiss(animated: true) { dismiss(animated: true) {
self.selectedIndex = tab.rawValue self.selectedIndex = tab.rawValue
} }
@ -141,8 +141,8 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
return return
} }
NSLayoutConstraint.deactivate(fastSwitcherConstraints) NSLayoutConstraint.deactivate(fastSwitcherConstraints)
let isPortrait = view.bounds.width < view.bounds.height // using interfaceOrientation isn't ideal, but UITabBar buttons may lay out horizontally even in the compact size class
if traitCollection.horizontalSizeClass == .compact && isPortrait { if traitCollection.horizontalSizeClass == .compact && interfaceOrientation.isPortrait {
fastSwitcherConstraints = [ fastSwitcherConstraints = [
fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4), fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4),
// tab bar button image width is 30 // tab bar button image width is 30
@ -291,18 +291,18 @@ extension MainTabBarViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) { func select(route: TuskerRoute, animated: Bool) {
switch route { switch route {
case .timelines: case .timelines:
select(tab: .timelines, dismissPresented: true) select(tab: .timelines)
case .notifications: case .notifications:
select(tab: .notifications, dismissPresented: true) select(tab: .notifications)
case .myProfile: case .myProfile:
select(tab: .myProfile, dismissPresented: true) select(tab: .myProfile)
case .explore: case .explore:
select(tab: .explore, dismissPresented: true) select(tab: .explore)
case .bookmarks: case .bookmarks:
select(tab: .explore, dismissPresented: true) select(tab: .explore)
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated) getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
case .list(id: let id): case .list(id: let id):
select(tab: .explore, dismissPresented: true) select(tab: .explore)
if let list = mastodonController.getCachedList(id: id) { if let list = mastodonController.getCachedList(id: id) {
let nav = getNavigationController() let nav = getNavigationController()
_ = nav.popToRootViewController(animated: animated) _ = nav.popToRootViewController(animated: animated)
@ -325,7 +325,7 @@ extension MainTabBarViewController: TuskerRootViewController {
return return
} }
select(tab: .explore, dismissPresented: true) select(tab: .explore)
exploreNavController.popToRootViewController(animated: false) exploreNavController.popToRootViewController(animated: false)
// setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time // setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time

View File

@ -48,7 +48,11 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
$0.axis = .horizontal $0.axis = .horizontal
$0.alignment = .fill $0.alignment = .fill
$0.spacing = 8 $0.spacing = 8
$0.heightAnchor.constraint(equalToConstant: 30).isActive = true let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
// the collection view cell imposes a height constraint before it's calculated the actual height
// so let this constraint be broken temporarily to avoid unsatisfiable constraints log spam
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
} }
private lazy var actionLabel = MultiSourceEmojiLabel().configure { private lazy var actionLabel = MultiSourceEmojiLabel().configure {
@ -142,7 +146,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) { for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
let imageView = CachedImageView(cache: .avatars) let imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFit
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous imageView.layer.cornerCurve = .continuous
@ -236,9 +240,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
override var accessibilityLabel: String? { override var accessibilityLabel: String? {
get { get {
guard let first = group.notifications.first else { let first = group.notifications.first!
return nil
}
var str = "" var str = ""
switch group.kind { switch group.kind {
case .favourite: case .favourite:

View File

@ -45,7 +45,9 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
]).configure { ]).configure {
$0.axis = .horizontal $0.axis = .horizontal
$0.alignment = .fill $0.alignment = .fill
$0.heightAnchor.constraint(equalToConstant: 30).isActive = true let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
} }
private lazy var actionLabel = MultiSourceEmojiLabel().configure { private lazy var actionLabel = MultiSourceEmojiLabel().configure {
@ -119,7 +121,6 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() } avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) { for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
let imageView = CachedImageView(cache: .avatars) let imageView = CachedImageView(cache: .avatars)
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous imageView.layer.cornerCurve = .continuous

View File

@ -20,7 +20,6 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
} }
private let avatarImageView = CachedImageView(cache: .avatars).configure { private let avatarImageView = CachedImageView(cache: .avatars).configure {
$0.contentMode = .scaleAspectFill
$0.layer.masksToBounds = true $0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous $0.layer.cornerCurve = .continuous
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -48,7 +47,9 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
]).configure { ]).configure {
$0.axis = .horizontal $0.axis = .horizontal
$0.alignment = .fill $0.alignment = .fill
$0.heightAnchor.constraint(equalToConstant: 30).isActive = true let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
} }
private lazy var actionLabel = EmojiLabel().configure { private lazy var actionLabel = EmojiLabel().configure {

View File

@ -263,9 +263,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
return return
} }
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
guard snapshot.sectionIdentifiers.contains(.notifications) else {
return
}
let items = snapshot.itemIdentifiers(inSection: .notifications) let items = snapshot.itemIdentifiers(inSection: .notifications)
let toDelete = statusIDs.flatMap { id in let toDelete = statusIDs.flatMap { id in
items.lazy.filter { $0.group?.notifications.first?.status?.id == id } items.lazy.filter { $0.group?.notifications.first?.status?.id == id }
@ -398,19 +395,13 @@ extension NotificationsCollectionViewController {
var types = Set(Notification.Kind.allCases) var types = Set(Notification.Kind.allCases)
types.remove(.unknown) types.remove(.unknown)
allowedTypes.forEach { types.remove($0) } allowedTypes.forEach { types.remove($0) }
if !mastodonController.instanceFeatures.statusEditNotifications {
types.remove(.update)
}
if !mastodonController.instanceFeatures.statusNotifications {
types.remove(.status)
}
return Client.getNotifications(excludedTypes: Array(types), range: range) return Client.getNotifications(excludedTypes: Array(types), range: range)
} }
} }
private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] { private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] {
return notifications.compactMap { notif in return notifications.compactMap { notif in
if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite || notif.kind == .status) { if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite) {
let crumb = Breadcrumb(level: .fatal, category: "notifications") let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [ crumb.data = [
"id": notif.id, "id": notif.id,

View File

@ -91,6 +91,9 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
contentView.addSubview(iconView) contentView.addSubview(iconView)
vStack.translatesAutoresizingMaskIntoConstraints = false vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack) contentView.addSubview(vStack)
let vStackBottomConstraint = vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8)
// need something to break during intermediate layouts when the cell imposes a 44pt height :S
vStackBottomConstraint.priority = .init(999)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor), iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50), iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
@ -98,7 +101,7 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), vStackBottomConstraint,
]) ])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)

View File

@ -150,7 +150,7 @@ class OnboardingViewController: UINavigationController {
mastodonController.accountInfo = tempAccountInfo mastodonController.accountInfo = tempAccountInfo
updateStatus("Checking Credentials") updateStatus("Checking Credentials")
let ownAccount: AccountMO let ownAccount: Account
do { do {
ownAccount = try await retrying("Getting own account") { ownAccount = try await retrying("Getting own account") {
try await mastodonController.getOwnAccount() try await mastodonController.getOwnAccount()

View File

@ -77,12 +77,8 @@ struct TipJarView: View {
} }
} }
.onPreferenceChange(ButtonWidthKey.self) { newValue in .onPreferenceChange(ButtonWidthKey.self) { newValue in
if let buttonWidth {
self.buttonWidth = max(buttonWidth, newValue)
} else {
self.buttonWidth = newValue self.buttonWidth = newValue
} }
}
if let total = getTotalTips(), total > 0 { if let total = getTotalTips(), total > 0 {
Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍") Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍")

View File

@ -31,7 +31,7 @@ struct ReportSelectRulesView: View {
} }
var body: some View { var body: some View {
List(mastodonController.instance!.rules!) { rule in List(mastodonController.instance.rules!) { rule in
Button { Button {
if selectedRuleIDs.contains(rule.id) { if selectedRuleIDs.contains(rule.id) {
selectedRuleIDs.removeAll(where: { $0 == rule.id }) selectedRuleIDs.removeAll(where: { $0 == rule.id })

View File

@ -24,6 +24,9 @@ struct ReportView: View {
self.account = mastodonController.persistentContainer.account(for: report.accountID)! self.account = mastodonController.persistentContainer.account(for: report.accountID)!
self.mastodonController = mastodonController self.mastodonController = mastodonController
self._report = StateObject(wrappedValue: report) self._report = StateObject(wrappedValue: report)
if mastodonController.instance?.rules == nil {
report.reason = .spam
}
} }
var body: some View { var body: some View {
@ -170,11 +173,6 @@ struct ReportView: View {
} }
} }
} }
.onReceive(mastodonController.$instance) { instance in
if instance?.rules == nil {
report.reason = .spam
}
}
} }
private func sendReport() { private func sendReport() {

View File

@ -31,7 +31,7 @@ class StatusEditPollView: UIStackView, StatusContentPollView {
for option in poll?.options ?? [] { for option in poll?.options ?? [] {
// the edit poll doesn't actually include the multiple value // the edit poll doesn't actually include the multiple value
let icon = PollOptionCheckboxView() let icon = PollOptionCheckboxView(multiple: false)
icon.readOnly = false // this is a lie, but it's only used for stylistic changes icon.readOnly = false // this is a lie, but it's only used for stylistic changes
let label = EmojiLabel() let label = EmojiLabel()
label.text = option.title label.text = option.title

View File

@ -518,7 +518,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
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.fault("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")
} }

View File

@ -486,7 +486,6 @@ class CustomAlertPresentationAnimation: NSObject, UIViewControllerAnimatedTransi
let container = transitionContext.containerView let container = transitionContext.containerView
container.addSubview(alert.view) container.addSubview(alert.view)
alert.view.frame = container.bounds
guard transitionContext.isAnimated else { guard transitionContext.isAnimated else {
presenter.view.tintAdjustmentMode = .dimmed presenter.view.tintAdjustmentMode = .dimmed

View File

@ -63,20 +63,19 @@ class TimelineLikeController<Item: Sendable> {
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
state = .loadingInitial(token, hasAddedLoadingIndicator: false) state = .loadingInitial(token, hasAddedLoadingIndicator: false)
await emit(event: .addLoadingIndicator) let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
state = .loadingInitial(token, hasAddedLoadingIndicator: true)
do { do {
let items = try await delegate.loadInitial() let items = try await delegate.loadInitial()
guard case .loadingInitial(token, _) = state else { guard case .loadingInitial(token, _) = state else {
return return
} }
await loadingIndicator.end()
await emit(event: .replaceAllItems(items, token)) await emit(event: .replaceAllItems(items, token))
await emit(event: .removeLoadingIndicator)
state = .idle state = .idle
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
await emit(event: .removeLoadingIndicator) await loadingIndicator.end()
await emit(event: .loadAllError(error, token)) await emit(event: .loadAllError(error, token))
state = .notLoadedInitial state = .notLoadedInitial
} }
@ -89,10 +88,9 @@ class TimelineLikeController<Item: Sendable> {
} }
let token = LoadAttemptToken() let token = LoadAttemptToken()
state = .restoringInitial(token, hasAddedLoadingIndicator: false) state = .restoringInitial(token, hasAddedLoadingIndicator: false)
await emit(event: .addLoadingIndicator) let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .restoringInitial(token, hasAddedLoadingIndicator: true))
state = .restoringInitial(token, hasAddedLoadingIndicator: true)
await doRestore() await doRestore()
await emit(event: .removeLoadingIndicator) await loadingIndicator.end()
state = .idle state = .idle
} }
@ -130,20 +128,19 @@ class TimelineLikeController<Item: Sendable> {
return return
} }
state = .loadingOlder(token, hasAddedLoadingIndicator: false) state = .loadingOlder(token, hasAddedLoadingIndicator: false)
await emit(event: .addLoadingIndicator) let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
state = .loadingOlder(token, hasAddedLoadingIndicator: true)
do { do {
let items = try await delegate.loadOlder() let items = try await delegate.loadOlder()
guard case .loadingOlder(token, _) = state else { guard case .loadingOlder(token, _) = state else {
return return
} }
await loadingIndicator.end()
await emit(event: .appendItems(items, token)) await emit(event: .appendItems(items, token))
await emit(event: .removeLoadingIndicator)
state = .idle state = .idle
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
await emit(event: .removeLoadingIndicator) await loadingIndicator.end()
await emit(event: .loadOlderError(error, token)) await emit(event: .loadOlderError(error, token))
state = .idle state = .idle
} }
@ -352,6 +349,34 @@ class TimelineLikeController<Item: Sendable> {
} }
} }
@MainActor
class DeferredLoadingIndicator {
private let owner: TimelineLikeController<Item>
private let addedIndicatorState: State
private let task: Task<Void, Error>
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
self.owner = owner
self.addedIndicatorState = addedIndicatorState
self.task = Task { @MainActor in
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
guard state == owner.state else {
return
}
await owner.emit(event: .addLoadingIndicator)
owner.transition(to: addedIndicatorState)
}
}
func end() async {
if owner.state == addedIndicatorState {
await owner.emit(event: .removeLoadingIndicator)
} else {
task.cancel()
}
}
}
} }
enum TimelineGapDirection { enum TimelineGapDirection {

View File

@ -101,8 +101,7 @@ class AttachmentsContainerView: UIView {
accessibilityElements.append(attachmentView) accessibilityElements.append(attachmentView)
if Preferences.shared.showUncroppedMediaInline, if Preferences.shared.showUncroppedMediaInline,
let attachmentAspectRatio = attachmentView.attachmentAspectRatio { let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
// clamp to prevent excessively short/tall attachments aspectRatio = attachmentAspectRatio
aspectRatio = max(min(attachmentAspectRatio, 2/1), 1/2)
} }
case 2: case 2:
let left = createAttachmentView(index: 0, hSize: .half, vSize: .full) let left = createAttachmentView(index: 0, hSize: .half, vSize: .full)

View File

@ -46,13 +46,6 @@ class CachedImageView: UIImageView {
} }
} }
func showOnlyBlurHash(_ blurhash: String, for url: URL) {
if url != self.url {
self.url = url
updateBlurhash(blurhash, for: url)
}
}
@objc private func preferencesChanged() { @objc private func preferencesChanged() {
if isGrayscale != Preferences.shared.grayscaleImages { if isGrayscale != Preferences.shared.grayscaleImages {
updateImage() updateImage()

View File

@ -75,10 +75,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
textContainerInset = .zero textContainerInset = .zero
textContainer.lineFragmentPadding = 0 textContainer.lineFragmentPadding = 0
linkTextAttributes = [
.foregroundColor: UIColor.tintColor
]
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer // the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:))) let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
addGestureRecognizer(recognizer) addGestureRecognizer(recognizer)

View File

@ -10,8 +10,6 @@ import UIKit
class PollOptionCheckboxView: UIView { class PollOptionCheckboxView: UIView {
private static let size: CGFloat = 20
var isChecked: Bool = false { var isChecked: Bool = false {
didSet { didSet {
updateStyle() updateStyle()
@ -27,19 +25,16 @@ class PollOptionCheckboxView: UIView {
updateStyle() updateStyle()
} }
} }
var multiple: Bool = false {
didSet {
updateStyle()
}
}
private let imageView: UIImageView private let imageView: UIImageView
init() { init(multiple: Bool) {
imageView = UIImageView(image: UIImage(systemName: "checkmark")!) imageView = UIImageView(image: UIImage(systemName: "checkmark")!)
super.init(frame: .zero) super.init(frame: .zero)
let size: CGFloat = 20
layer.cornerRadius = (multiple ? 0.1 : 0.5) * size
layer.cornerCurve = .continuous layer.cornerCurve = .continuous
layer.borderWidth = 2 layer.borderWidth = 2
@ -51,7 +46,7 @@ class PollOptionCheckboxView: UIView {
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
widthAnchor.constraint(equalTo: heightAnchor), widthAnchor.constraint(equalTo: heightAnchor),
widthAnchor.constraint(equalToConstant: PollOptionCheckboxView.size), widthAnchor.constraint(equalToConstant: size),
imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3), imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3),
imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3), imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3),
@ -69,8 +64,6 @@ class PollOptionCheckboxView: UIView {
} }
private func updateStyle() { private func updateStyle() {
layer.cornerRadius = (multiple ? 0.1 : 0.5) * PollOptionCheckboxView.size
imageView.isHidden = !isChecked imageView.isHidden = !isChecked
if voted || readOnly { if voted || readOnly {
layer.borderColor = UIColor.clear.cgColor layer.borderColor = UIColor.clear.cgColor

View File

@ -11,43 +11,35 @@ import Pachyderm
class PollOptionView: UIView { class PollOptionView: UIView {
private static let minHeight: CGFloat = 35
private static let cornerRadius = 0.1 * minHeight
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25) private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private(set) var label: EmojiLabel! private(set) var label: EmojiLabel!
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure { private(set) var checkbox: PollOptionCheckboxView?
$0.translatesAutoresizingMaskIntoConstraints = false
}
var checkboxIfInitialized: PollOptionCheckboxView? {
_checkbox.valueIfInitialized
}
private var percentLabel: UILabel!
@Lazy private var fillView: UIView = UIView().configure {
$0.translatesAutoresizingMaskIntoConstraints = false
$0.backgroundColor = .tintColor.withAlphaComponent(0.6)
$0.layer.zPosition = -1
$0.layer.cornerRadius = PollOptionView.cornerRadius
$0.layer.cornerCurve = .continuous
}
private var labelLeadingToSelfConstraint: NSLayoutConstraint!
private var fillViewWidthConstraint: NSLayoutConstraint?
init() { init(poll: Poll, option: Poll.Option, mastodonController: MastodonController) {
super.init(frame: .zero) super.init(frame: .zero)
layer.cornerRadius = PollOptionView.cornerRadius let minHeight: CGFloat = 35
layer.cornerRadius = 0.1 * minHeight
layer.cornerCurve = .continuous layer.cornerCurve = .continuous
backgroundColor = PollOptionView.unselectedBackgroundColor backgroundColor = PollOptionView.unselectedBackgroundColor
let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired
if showCheckbox {
checkbox = PollOptionCheckboxView(multiple: poll.multiple)
checkbox!.translatesAutoresizingMaskIntoConstraints = false
addSubview(checkbox!)
}
label = EmojiLabel() label = EmojiLabel()
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
label.numberOfLines = 0 label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .callout) label.font = .preferredFont(forTextStyle: .callout)
label.text = option.title
label.setEmojis(poll.emojis, identifier: poll.id)
addSubview(label) addSubview(label)
labelLeadingToSelfConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8)
percentLabel = UILabel() let percentLabel = UILabel()
percentLabel.translatesAutoresizingMaskIntoConstraints = false percentLabel.translatesAutoresizingMaskIntoConstraints = false
percentLabel.text = "100%" percentLabel.text = "100%"
percentLabel.font = label.font percentLabel.font = label.font
@ -56,48 +48,6 @@ class PollOptionView: UIView {
percentLabel.setContentHuggingPriority(.required, for: .horizontal) percentLabel.setContentHuggingPriority(.required, for: .horizontal)
addSubview(percentLabel) addSubview(percentLabel)
NSLayoutConstraint.activate([
heightAnchor.constraint(greaterThanOrEqualToConstant: PollOptionView.minHeight),
label.topAnchor.constraint(equalTo: topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
isAccessibilityElement = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func updateUI(poll: Poll, option: Poll.Option, ownVoted: Bool, mastodonController: MastodonController) {
let showCheckbox = poll.ownVotes?.isEmpty == false || !poll.effectiveExpired
if showCheckbox {
checkbox.isChecked = ownVoted
checkbox.voted = poll.voted ?? false
labelLeadingToSelfConstraint.isActive = false
if checkbox.superview != self {
addSubview(checkbox)
NSLayoutConstraint.activate([
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
])
}
} else if !showCheckbox {
labelLeadingToSelfConstraint.isActive = true
_checkbox.valueIfInitialized?.removeFromSuperview()
}
label.text = option.title
label.setEmojis(poll.emojis, identifier: poll.id)
accessibilityLabel = option.title accessibilityLabel = option.title
if (poll.voted ?? false) || poll.effectiveExpired, if (poll.voted ?? false) || poll.effectiveExpired,
@ -115,23 +65,56 @@ class PollOptionView: UIView {
percentLabel.isHidden = false percentLabel.isHidden = false
percentLabel.text = percent percentLabel.text = percent
if fillView.superview != self { let fillView = UIView()
fillView.translatesAutoresizingMaskIntoConstraints = false
fillView.backgroundColor = .tintColor.withAlphaComponent(0.6)
fillView.layer.zPosition = -1
fillView.layer.cornerRadius = layer.cornerRadius
fillView.layer.cornerCurve = .continuous
addSubview(fillView) addSubview(fillView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
fillView.leadingAnchor.constraint(equalTo: leadingAnchor), fillView.leadingAnchor.constraint(equalTo: leadingAnchor),
fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac),
fillView.topAnchor.constraint(equalTo: topAnchor), fillView.topAnchor.constraint(equalTo: topAnchor),
fillView.bottomAnchor.constraint(equalTo: bottomAnchor), fillView.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
}
fillViewWidthConstraint?.isActive = false
fillViewWidthConstraint = fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac)
fillViewWidthConstraint!.isActive = true
accessibilityLabel! += ", \(percent)" accessibilityLabel! += ", \(percent)"
} else {
percentLabel.isHidden = true
_fillView.valueIfInitialized?.removeFromSuperview()
} }
let minHeightConstraint = heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
// on the first layout, something is weird and this becomes ambiguous even though it's fine on subsequent layouts
// this keeps autolayout from complaining
minHeightConstraint.priority = .required - 1
NSLayoutConstraint.activate([
minHeightConstraint,
label.topAnchor.constraint(equalTo: topAnchor, constant: 4),
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4),
label.trailingAnchor.constraint(equalTo: percentLabel.leadingAnchor, constant: -4),
percentLabel.topAnchor.constraint(equalTo: topAnchor),
percentLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
percentLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
])
if let checkbox {
NSLayoutConstraint.activate([
checkbox.centerYAnchor.constraint(equalTo: centerYAnchor),
checkbox.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: checkbox.trailingAnchor, constant: 8),
])
} else {
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true
}
isAccessibilityElement = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
} }
} }

View File

@ -14,7 +14,7 @@ class PollOptionsView: UIControl {
var mastodonController: MastodonController! var mastodonController: MastodonController!
var checkedOptionIndices: [Int] { var checkedOptionIndices: [Int] {
options.enumerated().filter { $0.element.checkboxIfInitialized?.isChecked == true }.map(\.offset) options.enumerated().filter { $0.element.checkbox?.isChecked == true }.map(\.offset)
} }
var checkedOptionsChanged: (() -> Void)? var checkedOptionsChanged: (() -> Void)?
@ -32,15 +32,10 @@ class PollOptionsView: UIControl {
override var isEnabled: Bool { override var isEnabled: Bool {
didSet { didSet {
options.forEach { $0.checkboxIfInitialized?.readOnly = !isEnabled } options.forEach { $0.checkbox?.readOnly = !isEnabled }
} }
} }
override var accessibilityElements: [Any]? {
get { options }
set {}
}
override init(frame: CGRect) { override init(frame: CGRect) {
stack = UIStackView() stack = UIStackView()
@ -66,22 +61,20 @@ class PollOptionsView: UIControl {
func updateUI(poll: Poll) { func updateUI(poll: Poll) {
self.poll = poll self.poll = poll
if poll.options.count > options.count { options.forEach { $0.removeFromSuperview() }
for _ in 0..<(poll.options.count - options.count) {
let optView = PollOptionView() options = poll.options.enumerated().map { (index, opt) in
options.append(optView) let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController)
stack.addArrangedSubview(optView) if let checkbox = optionView.checkbox {
} checkbox.readOnly = !isEnabled
} else if poll.options.count < options.count { checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
for _ in 0..<(options.count - poll.options.count) { checkbox.voted = poll.voted ?? false
options.removeLast().removeFromSuperview()
} }
stack.addArrangedSubview(optionView)
return optionView
} }
for (index, (view, opt)) in zip(options, poll.options).enumerated() { accessibilityElements = options
view.updateUI(poll: poll, option: opt, ownVoted: poll.ownVotes?.contains(index) ?? false, mastodonController: mastodonController)
view.checkboxIfInitialized?.readOnly = !isEnabled
}
} }
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
@ -96,13 +89,13 @@ class PollOptionsView: UIControl {
private func selectOption(_ option: PollOptionView) { private func selectOption(_ option: PollOptionView) {
if poll.multiple { if poll.multiple {
option.checkboxIfInitialized?.isChecked.toggle() option.checkbox?.isChecked.toggle()
} else { } else {
for opt in options { for opt in options {
if opt === option { if opt === option {
opt.checkboxIfInitialized?.isChecked = true opt.checkbox?.isChecked = true
} else { } else {
opt.checkboxIfInitialized?.isChecked = false opt.checkbox?.isChecked = false
} }
} }
} }
@ -112,11 +105,7 @@ class PollOptionsView: UIControl {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// don't let subviews receive touch events // don't let subviews receive touch events
if isEnabled {
return self return self
} else {
return nil
}
} }
// MARK: - UIControl // MARK: - UIControl

View File

@ -32,12 +32,10 @@ class PollVoteButton: UIView {
button.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false
button.setTitleColor(.secondaryLabel, for: .disabled) button.setTitleColor(.secondaryLabel, for: .disabled)
button.contentHorizontalAlignment = .trailing
embedSubview(button) embedSubview(button)
#if targetEnvironment(macCatalyst) #if targetEnvironment(macCatalyst)
label.textColor = .secondaryLabel label.textColor = .secondaryLabel
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .right
embedSubview(label) embedSubview(label)
#endif #endif

View File

@ -67,7 +67,6 @@ class StatusPollView: UIView, StatusContentPollView {
voteButton.translatesAutoresizingMaskIntoConstraints = false voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed)) voteButton.addTarget(self, action: #selector(votePressed))
voteButton.setFont(infoLabel.font) voteButton.setFont(infoLabel.font)
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
addSubview(voteButton) addSubview(voteButton)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -78,7 +77,7 @@ class StatusPollView: UIView, StatusContentPollView {
infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor), infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor), infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor), infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
infoLabel.trailingAnchor.constraint(equalTo: voteButton.leadingAnchor, constant: -8), infoLabel.trailingAnchor.constraint(lessThanOrEqualTo: voteButton.leadingAnchor, constant: -8),
voteButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44), voteButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
voteButton.trailingAnchor.constraint(equalTo: trailingAnchor), voteButton.trailingAnchor.constraint(equalTo: trailingAnchor),
@ -142,7 +141,7 @@ class StatusPollView: UIView, StatusContentPollView {
} }
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat { func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
guard poll != nil else { return 0 } guard let poll else { return 0 }
return optionsView.estimateHeight(effectiveWidth: effectiveWidth) + infoLabel.sizeThatFits(UIView.layoutFittingExpandedSize).height return optionsView.estimateHeight(effectiveWidth: effectiveWidth) + infoLabel.sizeThatFits(UIView.layoutFittingExpandedSize).height
} }

View File

@ -19,7 +19,6 @@ class ProfileHeaderButton: UIButton {
var backgroundConfig = UIBackgroundConfiguration.clear() var backgroundConfig = UIBackgroundConfiguration.clear()
backgroundConfig.visualEffect = UIBlurEffect(style: .systemThickMaterial) backgroundConfig.visualEffect = UIBlurEffect(style: .systemThickMaterial)
config.background = backgroundConfig config.background = backgroundConfig
config.imagePadding = 4
self.configuration = config self.configuration = config
} }

View File

@ -75,13 +75,6 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
invalidateIntrinsicContentSize()
}
}
private func createOptionViews() { private func createOptionViews() {
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() } optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }

View File

@ -28,7 +28,6 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
private static let avatarImageViewSize: CGFloat = 50 private static let avatarImageViewSize: CGFloat = 50
private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure {
$0.contentMode = .scaleAspectFill
$0.layer.masksToBounds = true $0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous $0.layer.cornerCurve = .continuous
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -141,23 +140,12 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.isPointerInteractionEnabled = true $0.isPointerInteractionEnabled = true
} }
// using a UIStackView for this does not layout correctly on the first pass private lazy var actionsCountHStack = UIStackView(arrangedSubviews: [
// (everything is shifted slightly to the right for some reason) reblogsCountButton,
// so do it manually, since there are only two subvviews favoritesCountButton,
private lazy var actionsCountHStack = UIView().configure { ]).configure {
reblogsCountButton.translatesAutoresizingMaskIntoConstraints = false $0.axis = .horizontal
$0.addSubview(reblogsCountButton) $0.spacing = 8
favoritesCountButton.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(favoritesCountButton)
NSLayoutConstraint.activate([
reblogsCountButton.leadingAnchor.constraint(equalTo: $0.leadingAnchor),
reblogsCountButton.topAnchor.constraint(equalTo: $0.topAnchor),
reblogsCountButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
favoritesCountButton.leadingAnchor.constraint(equalTo: reblogsCountButton.trailingAnchor, constant: 8),
favoritesCountButton.topAnchor.constraint(equalTo: $0.topAnchor),
favoritesCountButton.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
favoritesCountButton.trailingAnchor.constraint(equalTo: $0.trailingAnchor),
])
} }
private let timestampAndClientLabel = UILabel().configure { private let timestampAndClientLabel = UILabel().configure {
@ -338,12 +326,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
} }
override func updateConfiguration(using state: UICellConfigurationState) { override func updateConfiguration(using state: UICellConfigurationState) {
var config = UIBackgroundConfiguration.listPlainCell().updated(for: state) backgroundConfiguration = .appListPlainCell(for: state)
// conv main status isn't selectable
if !state.isFocused {
config.backgroundColor = .appBackground
}
backgroundConfiguration = config
} }
// MARK: Configure UI // MARK: Configure UI

View File

@ -30,7 +30,7 @@ class StatusCardView: UIView {
private var titleLabel: UILabel! private var titleLabel: UILabel!
private var descriptionLabel: UILabel! private var descriptionLabel: UILabel!
private var domainLabel: UILabel! private var domainLabel: UILabel!
private var imageView: StatusCardImageView! private var imageView: CachedImageView!
private var placeholderImageView: UIImageView! private var placeholderImageView: UIImageView!
private var leadingSpacer: UIView! private var leadingSpacer: UIView!
private var trailingSpacer: UIView! private var trailingSpacer: UIView!
@ -80,7 +80,7 @@ class StatusCardView: UIView {
vStack.alignment = .leading vStack.alignment = .leading
vStack.spacing = 0 vStack.spacing = 0
imageView = StatusCardImageView(cache: .attachments) imageView = CachedImageView(cache: .attachments)
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
@ -167,18 +167,7 @@ class StatusCardView: UIView {
} }
if let image = card.image { if let image = card.image {
if status.sensitive {
if let blurhash = card.blurhash {
imageView.blurImage = false
imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
} else {
// if we don't have a blurhash, load the image and show it behind a blur
imageView.blurImage = true
imageView.update(for: URL(image), blurhash: nil)
}
} else {
imageView.update(for: URL(image), blurhash: card.blurhash) imageView.update(for: URL(image), blurhash: card.blurhash)
}
imageView.isHidden = false imageView.isHidden = false
leadingSpacer.isHidden = true leadingSpacer.isHidden = true
} else { } else {
@ -268,25 +257,3 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
} }
} }
} }
private class StatusCardImageView: CachedImageView {
@Lazy private var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
var blurImage = false {
didSet {
if blurImage {
if !_blurView.isInitialized {
blurView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurView)
NSLayoutConstraint.activate([
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
} else {
_blurView.valueIfInitialized?.removeFromSuperview()
}
}
}
}

View File

@ -60,8 +60,7 @@ extension StatusCollectionViewCell {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID } .filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] _ in .sink { [unowned self] _ in
if let mastodonController = self.mastodonController, if let status = self.mastodonController.persistentContainer.status(for: self.statusID) {
let status = mastodonController.persistentContainer.status(for: self.statusID) {
// update immediately w/o animation // update immediately w/o animation
self.favoriteButton.active = status.favourited self.favoriteButton.active = status.favourited
self.reblogButton.active = status.reblogged self.reblogButton.active = status.reblogged
@ -125,19 +124,7 @@ extension StatusCollectionViewCell {
statusState.collapsed = false statusState.collapsed = false
} }
} }
let expected = !statusState.collapsible! collapseButton.isHidden = !statusState.collapsible!
// Very very rarely, setting isHidden to false only seems to partially take effect:
// the button will be rendered, but isHidden will still return true, and the
// containing stack view won't have updated constraints for it and so the cell
// layout will be wrong and the button will overlap other views in the stack.
// So, as a truly cursed workaround, just try a few times in a row until reading
// back isHidden returns the correct value.
for _ in 0..<5 {
collapseButton.isHidden = expected
if collapseButton.isHidden == expected {
break
}
}
contentContainer.setCollapsed(statusState.collapsed!) contentContainer.setCollapsed(statusState.collapsed!)
if statusState.collapsed! { if statusState.collapsed! {
contentContainer.alpha = 0 contentContainer.alpha = 0

View File

@ -50,9 +50,8 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
private var isHiddenObservations: [NSKeyValueObservation] = [] private var isHiddenObservations: [NSKeyValueObservation] = []
private var visibleSubviews = IndexSet()
private var verticalConstraints: [NSLayoutConstraint] = [] private var verticalConstraints: [NSLayoutConstraint] = []
private var lastSubviewBottomConstraint: (UIView, NSLayoutConstraint)? private var lastSubviewBottomConstraint: NSLayoutConstraint?
private var zeroHeightConstraint: NSLayoutConstraint! private var zeroHeightConstraint: NSLayoutConstraint!
private var isCollapsed = false private var isCollapsed = false
@ -94,35 +93,31 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
} }
override func updateConstraints() { override func updateConstraints() {
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
if self.visibleSubviews != visibleSubviews {
self.visibleSubviews = visibleSubviews
NSLayoutConstraint.deactivate(verticalConstraints) NSLayoutConstraint.deactivate(verticalConstraints)
verticalConstraints = [] verticalConstraints = []
var lastVisibleSubview: UIView? var lastVisibleSubview: UIView?
for subviewIndex in visibleSubviews { for subview in arrangedSubviews {
let subview = arrangedSubviews[subviewIndex] guard !subview.isHidden else {
continue
}
if let lastVisibleSubview { if let lastVisibleSubview {
verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4)) verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4))
} else { } else {
verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor)) verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor))
} }
lastVisibleSubview = subview lastVisibleSubview = subview
} }
NSLayoutConstraint.activate(verticalConstraints) NSLayoutConstraint.activate(verticalConstraints)
}
if lastSubviewBottomConstraint == nil || arrangedSubviews[visibleSubviews.last!] !== lastSubviewBottomConstraint?.0 { lastSubviewBottomConstraint?.isActive = false
lastSubviewBottomConstraint?.1.isActive = false
// this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands // this constraint needs to have low priority so that during the collapse/expand animation, the content container is the view that shrinks/expands
let lastVisibleSubview = arrangedSubviews[visibleSubviews.last!] lastSubviewBottomConstraint = subviews.last(where: { !$0.isHidden })!.bottomAnchor.constraint(equalTo: bottomAnchor)
let constraint = lastVisibleSubview.bottomAnchor.constraint(equalTo: bottomAnchor) lastSubviewBottomConstraint!.isActive = !isCollapsed
constraint.isActive = !isCollapsed lastSubviewBottomConstraint!.priority = .defaultLow
constraint.priority = .defaultLow
lastSubviewBottomConstraint = (lastVisibleSubview, constraint)
}
zeroHeightConstraint.isActive = isCollapsed zeroHeightConstraint.isActive = isCollapsed
@ -138,7 +133,7 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
// don't call setNeedsUpdateConstraints b/c that destroys/recreates a bunch of other constraints // don't call setNeedsUpdateConstraints b/c that destroys/recreates a bunch of other constraints
// if there is no lastSubviewBottomConstraint, then we already need a constraint update, so we don't need to do anything here // if there is no lastSubviewBottomConstraint, then we already need a constraint update, so we don't need to do anything here
if let lastSubviewBottomConstraint { if let lastSubviewBottomConstraint {
lastSubviewBottomConstraint.1.isActive = !collapsed lastSubviewBottomConstraint.isActive = !collapsed
zeroHeightConstraint.isActive = collapsed zeroHeightConstraint.isActive = collapsed
} }
} }

View File

@ -35,11 +35,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.layer.masksToBounds = true $0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous $0.layer.cornerCurve = .continuous
$0.tintColor = .secondaryLabel $0.tintColor = .secondaryLabel
let heightConstraint = $0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.timelineReasonIconSize)
heightConstraint.identifier = "TimelineReason-Height"
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
// this needs to be lessThanOrEqualTo not just equalTo b/c otherwise intermediate layouts are broken // this needs to be lessThanOrEqualTo not just equalTo b/c otherwise intermediate layouts are broken
heightConstraint, $0.heightAnchor.constraint(lessThanOrEqualToConstant: TimelineStatusCollectionViewCell.timelineReasonIconSize),
$0.widthAnchor.constraint(equalTo: $0.heightAnchor), $0.widthAnchor.constraint(equalTo: $0.heightAnchor),
]) ])
} }
@ -62,26 +60,21 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.addSubview(contentVStack) $0.addSubview(contentVStack)
metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false
$0.addSubview(metaIndicatorsView) $0.addSubview(metaIndicatorsView)
let avatarTopConstraint = avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor)
avatarTopConstraint.identifier = "Avatar-Top"
let metaIndicatorsTopConstraint = metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4)
metaIndicatorsTopConstraint.identifier = "MetaIndicators-Top"
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor), avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor),
avatarTopConstraint, avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor),
contentVStack.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8), contentVStack.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8),
contentVStack.trailingAnchor.constraint(equalTo: $0.trailingAnchor), contentVStack.trailingAnchor.constraint(equalTo: $0.trailingAnchor),
contentVStack.topAnchor.constraint(equalTo: $0.topAnchor), contentVStack.topAnchor.constraint(equalTo: $0.topAnchor),
contentVStack.bottomAnchor.constraint(equalTo: $0.bottomAnchor), contentVStack.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
metaIndicatorsView.leadingAnchor.constraint(greaterThanOrEqualTo: $0.leadingAnchor), metaIndicatorsView.leadingAnchor.constraint(greaterThanOrEqualTo: $0.leadingAnchor),
metaIndicatorsView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor), metaIndicatorsView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
metaIndicatorsTopConstraint, metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4),
]) ])
} }
private static let avatarImageViewSize: CGFloat = 50 private static let avatarImageViewSize: CGFloat = 50
private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure { private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure {
$0.contentMode = .scaleAspectFill
$0.layer.masksToBounds = true $0.layer.masksToBounds = true
$0.layer.cornerCurve = .continuous $0.layer.cornerCurve = .continuous
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -183,7 +176,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
$0.tintAdjustmentMode = .normal $0.tintAdjustmentMode = .normal
$0.setContentHuggingPriority(.defaultHigh, for: .vertical) $0.setContentHuggingPriority(.defaultHigh, for: .vertical)
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
$0.setContentCompressionResistancePriority(.required, for: .vertical)
} }
let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure { let contentContainer = StatusContentContainer<StatusContentTextView, StatusPollView>(useTopSpacer: false).configure {
@ -323,12 +315,17 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4) mainContainerTopToReblogLabelConstraint = mainContainer.topAnchor.constraint(equalTo: timelineReasonHStack.bottomAnchor, constant: 4)
mainContainerTopToReblogLabelConstraint.identifier = "MainContainerTopToReblog"
mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8) mainContainerTopToSelfConstraint = mainContainer.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 8)
mainContainerTopToSelfConstraint.identifier = "MainContainerTopToSelf" // when flipping between topToReblog and topToSelf constraints, the framework sometimes thinks both of them should be active simultaneously
// even though the code never does that; so let this one get broken temporarily
mainContainerTopToSelfConstraint.priority = .init(999)
mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4) mainContainerBottomToActionsConstraint = mainContainer.bottomAnchor.constraint(equalTo: actionsContainer.topAnchor, constant: -4)
mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6) mainContainerBottomToSelfConstraint = mainContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6)
let metaIndicatorsBottomConstraint = metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6)
// sometimes during intermediate layouts, there are conflicting constraints, so let this one get broken temporarily, to avoid a bunch of printing
metaIndicatorsBottomConstraint.priority = .init(999)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced // why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
timelineReasonHStack.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 4), timelineReasonHStack.topAnchor.constraint(equalTo: statusContainer.topAnchor, constant: 4),
@ -337,14 +334,13 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16), mainContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16),
mainContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16), mainContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16),
mainContainerBottomToActionsConstraint,
actionsContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16), actionsContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16),
actionsContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16), actionsContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16),
// yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven // yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven
actionsContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6), actionsContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6),
metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6), metaIndicatorsBottomConstraint,
]) ])
updateActionsVisibility() updateActionsVisibility()
@ -539,21 +535,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
fatalError() fatalError()
} }
self.statusState = state
let reblogStatus: StatusMO?
if let rebloggedStatus = status.reblog {
reblogStatus = status
reblogStatusID = statusID
rebloggerID = status.account.id
status = rebloggedStatus
} else {
reblogStatus = nil
reblogStatusID = nil
rebloggerID = nil
}
switch filterResult { switch filterResult {
case .allow: case .allow:
setContentViewMode(.status) setContentViewMode(.status)
@ -564,10 +545,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
attrStr.append(showStr) attrStr.append(showStr)
filteredLabel.attributedText = attrStr filteredLabel.attributedText = attrStr
setContentViewMode(.filtered) setContentViewMode(.filtered)
// still update id properties, so that info for other methods (e.g., context menus) is correct
self.statusID = status.id
self.accountID = status.account.id
return return
case .hide: case .hide:
fatalError("unreachable") fatalError("unreachable")
@ -575,11 +552,20 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
createObservers() createObservers()
self.statusState = state
var hideTimelineReason = true var hideTimelineReason = true
if let reblogStatus { if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
rebloggerID = status.account.id
hideTimelineReason = false hideTimelineReason = false
updateRebloggerLabel(reblogger: reblogStatus.account) updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus
} else {
reblogStatusID = nil
rebloggerID = nil
} }
if showFollowedHashtags { if showFollowedHashtags {
@ -593,14 +579,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
timelineReasonHStack.isHidden = hideTimelineReason timelineReasonHStack.isHidden = hideTimelineReason
// do this to make sure the currently active constraint is deactivated first mainContainerTopToReblogLabelConstraint.isActive = !hideTimelineReason
if hideTimelineReason { mainContainerTopToSelfConstraint.isActive = hideTimelineReason
mainContainerTopToReblogLabelConstraint.isActive = false
mainContainerTopToSelfConstraint.isActive = true
} else {
mainContainerTopToSelfConstraint.isActive = false
mainContainerTopToReblogLabelConstraint.isActive = true
}
doUpdateUI(status: status, precomputedContent: precomputedContent) doUpdateUI(status: status, precomputedContent: precomputedContent)
@ -699,11 +679,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
private func updateActionsVisibility() { private func updateActionsVisibility() {
if Preferences.shared.hideActionsInTimeline && !actionsContainer.isHidden { if Preferences.shared.hideActionsInTimeline {
actionsContainer.isHidden = true actionsContainer.isHidden = true
mainContainerBottomToActionsConstraint.isActive = false
mainContainerBottomToSelfConstraint.isActive = true mainContainerBottomToSelfConstraint.isActive = true
} else if !Preferences.shared.hideActionsInTimeline && actionsContainer.isHidden { mainContainerBottomToActionsConstraint.isActive = false
} else {
actionsContainer.isHidden = false actionsContainer.isHidden = false
mainContainerBottomToSelfConstraint.isActive = false mainContainerBottomToSelfConstraint.isActive = false
mainContainerBottomToActionsConstraint.isActive = true mainContainerBottomToActionsConstraint.isActive = true
@ -804,8 +784,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
} }
func contextMenuConfiguration() -> UIContextMenuConfiguration? { func contextMenuConfiguration() -> UIContextMenuConfiguration? {
guard let mastodonController, guard let status = mastodonController.persistentContainer.status(for: statusID) else {
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil return nil
} }
return UIContextMenuConfiguration { return UIContextMenuConfiguration {

View File

@ -125,7 +125,7 @@ private func captureError(_ error: Client.Error, in mastodonController: Mastodon
return return
} }
if let code = event.tags!["response_code"], if let code = event.tags!["response_code"],
code == "401" || code == "403" || code == "404" || code == "502" || code == "503" { code == "401" || code == "403" || code == "404" || code == "502" {
return return
} }
switch mastodonController.instanceFeatures.instanceType { switch mastodonController.instanceFeatures.instanceType {
@ -153,8 +153,8 @@ private func captureError(_ error: Client.Error, in mastodonController: Mastodon
event.tags!["instance_type"] = "pixelfed" event.tags!["instance_type"] = "pixelfed"
case .gotosocial: case .gotosocial:
event.tags!["instance_type"] = "gotosocial" event.tags!["instance_type"] = "gotosocial"
case .firefish(let calckeyVersion): case .calckey(let calckeyVersion):
event.tags!["instance_type"] = "firefish" event.tags!["instance_type"] = "calckey"
if let calckeyVersion { if let calckeyVersion {
event.tags!["calckey_version"] = calckeyVersion event.tags!["calckey_version"] = calckeyVersion
} }