Compare commits
No commits in common. "6c0564e0ee0015880cb6d1b6e7735df3047c040b" and "06f761bf56b188458b3b05f93abd602e7493f373" have entirely different histories.
6c0564e0ee
...
06f761bf56
|
@ -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
|
||||
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.
|
||||
|
||||
|
|
32
CHANGELOG.md
32
CHANGELOG.md
|
@ -1,37 +1,5 @@
|
|||
# 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)
|
||||
Bugfixes:
|
||||
- Fix broken animation when opening/closing expanded attachment view on Compose screen
|
||||
|
|
|
@ -77,8 +77,7 @@ class PostService: ObservableObject {
|
|||
pollOptions: draft.poll?.pollOptions.map(\.text),
|
||||
pollExpiresIn: draft.poll == nil ? nil : Int(draft.poll!.duration),
|
||||
pollMultiple: draft.poll?.multiple,
|
||||
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil,
|
||||
idempotencyKey: draft.id.uuidString
|
||||
localOnly: mastodonController.instanceFeatures.localOnlyPosts ? draft.localOnly : nil
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -49,9 +49,7 @@ class AttachmentRowController: ViewController {
|
|||
|
||||
private func removeAttachment() {
|
||||
withAnimation {
|
||||
var newAttachments = parent.draft.draftAttachments
|
||||
newAttachments.removeAll(where: { $0.id == attachment.id })
|
||||
parent.draft.attachments = NSMutableOrderedSet(array: newAttachments)
|
||||
parent.draft.attachments.remove(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,8 +70,8 @@ class AttachmentRowController: ViewController {
|
|||
private func recognizeText() {
|
||||
descriptionMode = .recognizingText
|
||||
|
||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
self.attachment.getData(features: self.parent.mastodonController.instanceFeatures, skipAllConversion: true) { result in
|
||||
let data: Data
|
||||
switch result {
|
||||
case .success((let d, _)):
|
||||
|
|
|
@ -18,7 +18,19 @@ class AutocompleteEmojisController: ViewController {
|
|||
|
||||
@Published var expanded = false
|
||||
@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) {
|
||||
self.composeController = composeController
|
||||
|
@ -65,20 +77,11 @@ class AutocompleteEmojisController: ViewController {
|
|||
|
||||
var shortcodes = Set<String>()
|
||||
var newEmojis = [Emoji]()
|
||||
var newEmojisBySection = [String: [Emoji]]()
|
||||
for emoji in emojis where !shortcodes.contains(emoji.shortcode) {
|
||||
newEmojis.append(emoji)
|
||||
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.emojisBySection = newEmojisBySection
|
||||
}
|
||||
|
||||
private func toggleExpanded() {
|
||||
|
@ -157,7 +160,7 @@ class AutocompleteEmojisController: ViewController {
|
|||
|
||||
private var horizontalScrollView: some View {
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(controller.emojis, id: \.shortcode) { emoji in
|
||||
Button(action: { controller.autocomplete(with: emoji) }) {
|
||||
HStack(spacing: 4) {
|
||||
|
@ -171,6 +174,8 @@ class AutocompleteEmojisController: ViewController {
|
|||
.frame(height: emojiSize)
|
||||
}
|
||||
.animation(.linear(duration: 0.2), value: controller.emojis)
|
||||
|
||||
Spacer(minLength: emojiSize)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: emojiSize + 16)
|
||||
|
|
|
@ -100,10 +100,9 @@ class ToolbarController: ViewController {
|
|||
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.frame(height: ToolbarController.height)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: [.bottom, .leading, .trailing])
|
||||
.background(.regularMaterial, ignoresSafeAreaEdges: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
Divider()
|
||||
.edgesIgnoringSafeArea([.leading, .trailing])
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
|
|
|
@ -136,7 +136,6 @@ extension DraftAttachment {
|
|||
|
||||
//private let attachmentTypeIdentifier = "space.vaccor.Tusker.composition-attachment"
|
||||
|
||||
private let imageType = UTType.image.identifier
|
||||
private let jpegType = UTType.jpeg.identifier
|
||||
private let pngType = UTType.png.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?
|
||||
// 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
|
||||
[/*typeIdentifier, */ gifType, jpegType, pngType, imageType, mp4Type, quickTimeType]
|
||||
[/*typeIdentifier, */gifType, jpegType, pngType, mp4Type, quickTimeType]
|
||||
}
|
||||
|
||||
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)
|
||||
attachment.id = UUID()
|
||||
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
|
||||
attachment.fileType = type.identifier
|
||||
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: UTType(typeIdentifier)!)
|
||||
attachment.fileType = typeIdentifier
|
||||
attachment.attachmentDescription = ""
|
||||
return attachment
|
||||
}
|
||||
|
|
|
@ -22,11 +22,10 @@ struct LanguagePicker: View {
|
|||
}
|
||||
|
||||
static func codeFromInputMode(_ mode: UITextInputMode) -> Locale.LanguageCode? {
|
||||
guard let bcp47Lang = mode.primaryLanguage,
|
||||
!bcp47Lang.isEmpty else {
|
||||
guard let bcp47Lang = mode.primaryLanguage else {
|
||||
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 == "-" {
|
||||
maybeIso639Code = maybeIso639Code[..<maybeIso639Code.index(before: maybeIso639Code.endIndex)]
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
import UIKit
|
||||
|
||||
public protocol DuckableViewController: UIViewController {
|
||||
var duckableDelegate: DuckableViewControllerDelegate? { get set }
|
||||
|
||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||
|
||||
func duckableViewControllerMayAttemptToDuck()
|
||||
|
@ -24,6 +26,10 @@ extension DuckableViewController {
|
|||
public func duckableViewControllerDidFinishAnimatingDuck() {}
|
||||
}
|
||||
|
||||
public protocol DuckableViewControllerDelegate: AnyObject {
|
||||
func duckableViewControllerWillDismiss(animated: Bool)
|
||||
}
|
||||
|
||||
public enum DuckAttemptAction {
|
||||
case duck
|
||||
case dismiss
|
||||
|
|
|
@ -11,7 +11,7 @@ let duckedCornerRadius: CGFloat = 10
|
|||
let detentHeight: CGFloat = 44
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
public class DuckableContainerViewController: UIViewController {
|
||||
public class DuckableContainerViewController: UIViewController, DuckableViewControllerDelegate {
|
||||
|
||||
public let child: UIViewController
|
||||
private var bottomConstraint: NSLayoutConstraint!
|
||||
|
@ -87,6 +87,7 @@ public class DuckableContainerViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func doPresentDuckable(_ viewController: DuckableViewController, animated: Bool, completion: (() -> Void)?) {
|
||||
viewController.duckableDelegate = self
|
||||
viewController.modalPresentationStyle = .custom
|
||||
viewController.transitioningDelegate = self
|
||||
present(viewController, animated: animated) {
|
||||
|
@ -95,10 +96,7 @@ public class DuckableContainerViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
func dismissalTransitionWillBegin() {
|
||||
guard case .presentingDucked(_, _) = state else {
|
||||
return
|
||||
}
|
||||
public func duckableViewControllerWillDismiss(animated: Bool) {
|
||||
state = .idle
|
||||
bottomConstraint.isActive = false
|
||||
bottomConstraint = child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
|
@ -146,7 +144,7 @@ public class DuckableContainerViewController: UIViewController {
|
|||
case .block:
|
||||
viewController.sheetPresentationController!.selectedDetentIdentifier = .large
|
||||
case .dismiss:
|
||||
// duckableViewControllerWillDismiss()
|
||||
duckableViewControllerWillDismiss(animated: true)
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
@ -191,7 +189,7 @@ public class DuckableContainerViewController: UIViewController {
|
|||
@available(iOS 16.0, *)
|
||||
extension DuckableContainerViewController: UIViewControllerTransitioningDelegate {
|
||||
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.prefersGrabberVisible = true
|
||||
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, *)
|
||||
extension DuckableContainerViewController: UISheetPresentationControllerDelegate {
|
||||
public func presentationController(_ presentationController: UIPresentationController, willPresentWithAdaptiveStyle style: UIModalPresentationStyle, transitionCoordinator: UIViewControllerTransitionCoordinator?) {
|
||||
|
|
|
@ -72,7 +72,7 @@ public class InstanceFeatures: ObservableObject {
|
|||
|
||||
public var probablySupportsMarkdown: Bool {
|
||||
switch instanceType {
|
||||
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
|
||||
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .calckey(_):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
@ -96,13 +96,7 @@ public class InstanceFeatures: ObservableObject {
|
|||
}
|
||||
|
||||
public var canFollowHashtags: Bool {
|
||||
if case .mastodon(_, let version) = instanceType {
|
||||
return version >= Version(4, 0, 0)
|
||||
} else if case .pleroma(.akkoma(let version)) = instanceType {
|
||||
return version >= Version(3, 4, 0)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
hasMastodonVersion(4, 0, 0)
|
||||
}
|
||||
|
||||
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 {
|
||||
instanceType.isPleroma(.akkoma(nil))
|
||||
}
|
||||
|
@ -151,7 +135,7 @@ public class InstanceFeatures: ObservableObject {
|
|||
public init() {
|
||||
}
|
||||
|
||||
public func update(instance: InstanceInfo, nodeInfo: NodeInfo?) {
|
||||
public func update(instance: Instance, nodeInfo: NodeInfo?) {
|
||||
let ver = instance.version.lowercased()
|
||||
// check glitch first b/c it still reports "mastodon" as the software in nodeinfo
|
||||
if ver.contains("glitch") {
|
||||
|
@ -195,8 +179,8 @@ public class InstanceFeatures: ObservableObject {
|
|||
instanceType = .pixelfed
|
||||
} else if nodeInfo?.software.name == "gotosocial" {
|
||||
instanceType = .gotosocial
|
||||
} else if ver.contains("firefish") || ver.contains("calckey") {
|
||||
instanceType = .firefish(nodeInfo?.software.version)
|
||||
} else if ver.contains("calckey") {
|
||||
instanceType = .calckey(nodeInfo?.software.version)
|
||||
} else {
|
||||
instanceType = .mastodon(.vanilla, Version(string: ver))
|
||||
}
|
||||
|
@ -235,7 +219,7 @@ extension InstanceFeatures {
|
|||
case pleroma(PleromaType)
|
||||
case pixelfed
|
||||
case gotosocial
|
||||
case firefish(String?)
|
||||
case calckey(String?)
|
||||
|
||||
var isMastodon: Bool {
|
||||
if case .mastodon(_, _) = self {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -113,9 +113,6 @@ public class Client {
|
|||
var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
|
||||
urlRequest.httpMethod = request.method.name
|
||||
urlRequest.httpBody = request.body.data
|
||||
for (name, value) in request.headers {
|
||||
urlRequest.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
if let mimeType = request.body.mimeType {
|
||||
urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
@ -400,22 +397,19 @@ public class Client {
|
|||
pollOptions: [String]? = nil,
|
||||
pollExpiresIn: Int? = nil,
|
||||
pollMultiple: Bool? = nil,
|
||||
localOnly: Bool? = nil, /* hometown only, not glitch */
|
||||
idempotencyKey: String) -> Request<Status> {
|
||||
var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||
"status" => text,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
"sensitive" => sensitive,
|
||||
"spoiler_text" => spoilerText,
|
||||
"visibility" => visibility?.rawValue,
|
||||
"language" => language,
|
||||
"poll[expires_in]" => pollExpiresIn,
|
||||
"poll[multiple]" => pollMultiple,
|
||||
"local_only" => localOnly,
|
||||
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
||||
req.headers["Idempotency-Key"] = idempotencyKey
|
||||
return req
|
||||
localOnly: Bool? = nil /* hometown only, not glitch */) -> Request<Status> {
|
||||
return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
|
||||
"status" => text,
|
||||
"content_type" => contentType.mimeType,
|
||||
"in_reply_to_id" => inReplyTo,
|
||||
"sensitive" => sensitive,
|
||||
"spoiler_text" => spoilerText,
|
||||
"visibility" => visibility?.rawValue,
|
||||
"language" => language,
|
||||
"poll[expires_in]" => pollExpiresIn,
|
||||
"poll[multiple]" => pollMultiple,
|
||||
"local_only" => localOnly,
|
||||
] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
|
||||
}
|
||||
|
||||
public static func editStatus(
|
||||
|
|
|
@ -107,7 +107,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct Configuration: Codable, Sendable {
|
||||
public struct Configuration: Decodable, Sendable {
|
||||
public let statuses: StatusesConfiguration
|
||||
public let mediaAttachments: MediaAttachmentsConfiguration
|
||||
/// Use Instance.pollsConfiguration to support older instance that don't have this nested
|
||||
|
@ -122,7 +122,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct StatusesConfiguration: Codable, Sendable {
|
||||
public struct StatusesConfiguration: Decodable, Sendable {
|
||||
public let maxCharacters: Int
|
||||
public let maxMediaAttachments: Int
|
||||
public let charactersReservedPerURL: Int
|
||||
|
@ -136,7 +136,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct MediaAttachmentsConfiguration: Codable, Sendable {
|
||||
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
|
||||
public let supportedMIMETypes: [String]
|
||||
public let imageSizeLimit: Int
|
||||
public let imageMatrixLimit: Int
|
||||
|
@ -156,7 +156,7 @@ extension Instance {
|
|||
}
|
||||
|
||||
extension Instance {
|
||||
public struct PollsConfiguration: Codable, Sendable {
|
||||
public struct PollsConfiguration: Decodable, Sendable {
|
||||
public let maxOptions: Int
|
||||
public let maxCharactersPerOption: Int
|
||||
public let minExpiration: TimeInterval
|
||||
|
|
|
@ -8,11 +8,11 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct NodeInfo: Decodable, Sendable, Equatable {
|
||||
public struct NodeInfo: Decodable, Sendable {
|
||||
public let version: String
|
||||
public let software: Software
|
||||
|
||||
public struct Software: Decodable, Sendable, Equatable {
|
||||
public struct Software: Decodable, Sendable {
|
||||
public let name: String
|
||||
public let version: String
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ public struct Request<ResultType: Decodable>: Sendable {
|
|||
let endpoint: Endpoint
|
||||
let body: Body
|
||||
var queryParameters: [Parameter]
|
||||
var headers: [String: String] = [:]
|
||||
var additionalAcceptableHTTPCodes: [Int] = []
|
||||
|
||||
init(method: Method, path: Endpoint, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
|
||||
|
|
|
@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
|
|||
}
|
||||
})
|
||||
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
|
||||
|
|
|
@ -79,14 +79,7 @@ class ShareViewController: UIViewController {
|
|||
var attachments: [DraftAttachment] = []
|
||||
|
||||
for itemProvider in inputItem.attachments ?? [] {
|
||||
// attachments have the highest priority, but only given this heuristic
|
||||
// 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 let attached: NSURL = await getObject(from: itemProvider) {
|
||||
if url == nil {
|
||||
url = attached as URL
|
||||
}
|
||||
|
@ -94,6 +87,8 @@ class ShareViewController: UIViewController {
|
|||
if text.isEmpty {
|
||||
text = s as String
|
||||
}
|
||||
} else if let attachment: DraftAttachment = await getObject(from: itemProvider) {
|
||||
attachments.append(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,7 +29,6 @@
|
|||
D601FA61297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA60297B539E00A8E8B5 /* ConversationMainStatusCollectionViewCell.swift */; };
|
||||
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D601FA81297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift */; };
|
||||
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 */; };
|
||||
D6093FB725BE0CF3004811E6 /* TrendHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6093FB625BE0CF3004811E6 /* TrendHistoryView.swift */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -956,7 +954,6 @@
|
|||
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */,
|
||||
D68A76E229524D2A001DA1B3 /* ListMO.swift */,
|
||||
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */,
|
||||
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||
);
|
||||
|
@ -1962,7 +1959,6 @@
|
|||
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */,
|
||||
D6D79F532A0FFE3200AB2315 /* ToggleableButton.swift in Sources */,
|
||||
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
|
||||
D608470F2A245D1F00C17380 /* ActiveInstance.swift in Sources */,
|
||||
D6ADB6F028ED1F25009924AB /* CachedImageView.swift in Sources */,
|
||||
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
|
||||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */,
|
||||
|
@ -2390,7 +2386,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2398,7 +2394,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -2456,7 +2452,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2465,7 +2461,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2482,7 +2478,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
|
@ -2493,7 +2489,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2511,7 +2507,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
|
@ -2522,7 +2518,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2540,7 +2536,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
|
@ -2551,7 +2547,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2695,7 +2691,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2703,7 +2699,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||
|
@ -2726,7 +2722,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
|
@ -2734,7 +2730,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
OTHER_CODE_SIGN_FLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
@ -2832,7 +2828,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2841,7 +2837,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
@ -2858,7 +2854,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 100;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
|
@ -2867,7 +2863,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2023.6;
|
||||
MARKETING_VERSION = 2023.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
|
|
@ -23,12 +23,15 @@ class MastodonController: ObservableObject {
|
|||
@available(*, message: "do something less dumb")
|
||||
static var first: MastodonController { all.first!.value }
|
||||
|
||||
@MainActor
|
||||
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
|
||||
if let controller = all[account] {
|
||||
return controller
|
||||
} 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
|
||||
return controller
|
||||
}
|
||||
|
@ -43,7 +46,7 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
|
||||
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
|
||||
var accountInfo: UserAccountInfo?
|
||||
|
@ -52,9 +55,8 @@ class MastodonController: ObservableObject {
|
|||
let client: Client!
|
||||
let instanceFeatures = InstanceFeatures()
|
||||
|
||||
@Published private(set) var account: AccountMO?
|
||||
@Published private(set) var instance: Instance?
|
||||
@Published private(set) var instanceInfo: InstanceInfo!
|
||||
@Published private(set) var account: Account!
|
||||
@Published private(set) var instance: Instance!
|
||||
@Published private(set) var nodeInfo: NodeInfo!
|
||||
@Published private(set) var lists: [List] = []
|
||||
@Published private(set) var customEmojis: [Emoji]?
|
||||
|
@ -70,46 +72,24 @@ class MastodonController: ObservableObject {
|
|||
accountInfo != nil
|
||||
}
|
||||
|
||||
// main-actor b/c fetchActiveAccountID and fetchActiveInstance use the viewContext
|
||||
@MainActor
|
||||
init(instanceURL: URL, accountInfo: UserAccountInfo?) {
|
||||
init(instanceURL: URL, transient: Bool = false) {
|
||||
self.instanceURL = instanceURL
|
||||
self.accountInfo = accountInfo
|
||||
self.accountInfo = nil
|
||||
self.client = Client(baseURL: instanceURL, session: .appDefault)
|
||||
self.transient = accountInfo == nil
|
||||
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)
|
||||
self.transient = transient
|
||||
|
||||
$instance
|
||||
.compactMap { $0 }
|
||||
.sink { [unowned self] in
|
||||
self.updateActiveInstance(from: $0)
|
||||
self.instanceInfo = InstanceInfo(instance: $0)
|
||||
.combineLatest($nodeInfo)
|
||||
.compactMap { (instance, nodeInfo) in
|
||||
if let instance {
|
||||
return (instance, nodeInfo)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
.sink { [unowned self] (instance, nodeInfo) in
|
||||
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
|
||||
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
@ -123,12 +103,6 @@ class MastodonController: ObservableObject {
|
|||
.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
|
||||
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? {
|
||||
return client.run(request, completion: completion)
|
||||
|
@ -233,8 +207,8 @@ class MastodonController: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) {
|
||||
if let account {
|
||||
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
|
||||
if account != nil {
|
||||
completion?(.success(account))
|
||||
} else {
|
||||
let request = Client.getSelfAccount()
|
||||
|
@ -244,25 +218,25 @@ class MastodonController: ObservableObject {
|
|||
completion?(.failure(error))
|
||||
|
||||
case let .success(account, _):
|
||||
let context = self.persistentContainer.viewContext
|
||||
context.perform {
|
||||
let accountMO: AccountMO
|
||||
if let existing = self.persistentContainer.account(for: account.id, in: context) {
|
||||
accountMO = existing
|
||||
existing.updateFrom(apiAccount: account, container: self.persistentContainer)
|
||||
DispatchQueue.main.async {
|
||||
self.account = account
|
||||
}
|
||||
self.persistentContainer.backgroundContext.perform {
|
||||
if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) {
|
||||
accountMO.updateFrom(apiAccount: account, container: self.persistentContainer)
|
||||
} else {
|
||||
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
|
||||
// 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)
|
||||
}
|
||||
accountMO.active = true
|
||||
self.account = accountMO
|
||||
completion?(.success(accountMO))
|
||||
completion?(.success(account))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getOwnAccount() async throws -> AccountMO {
|
||||
func getOwnAccount() async throws -> Account {
|
||||
if let account = account {
|
||||
return account
|
||||
} 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) {
|
||||
if let emojis = self.customEmojis {
|
||||
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")
|
||||
crumb.data = [
|
||||
"instance": [
|
||||
|
|
|
@ -17,6 +17,7 @@ class ReblogService {
|
|||
private let status: StatusMO
|
||||
|
||||
var hapticFeedback = true
|
||||
var visibility: Visibility? = nil
|
||||
var requireConfirmation = Preferences.shared.confirmBeforeReblog
|
||||
|
||||
init(status: StatusMO, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate) {
|
||||
|
@ -30,30 +31,26 @@ class ReblogService {
|
|||
requireConfirmation {
|
||||
presentConfirmationAlert()
|
||||
} else {
|
||||
await doToggleReblog(visibility: nil)
|
||||
await doToggleReblog()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentConfirmationAlert() {
|
||||
let image: UIImage?
|
||||
let reblogVisibilityActions: [CustomAlertController.MenuAction]
|
||||
let maximumVisibility = status.visibility
|
||||
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
|
||||
if mastodonController.instanceFeatures.reblogVisibility {
|
||||
image = UIImage(systemName: maximumVisibility.unfilledImageName)
|
||||
reblogVisibilityActions = [Visibility.unlisted, .private].compactMap { visibility in
|
||||
guard visibility < maximumVisibility else {
|
||||
return nil
|
||||
}
|
||||
return CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) {
|
||||
image = UIImage(systemName: Visibility.public.unfilledImageName)
|
||||
reblogVisibilityActions = [Visibility.unlisted, .private].map { visibility in
|
||||
CustomAlertController.MenuAction(title: "Reblog as \(visibility.displayName)", subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName)) {
|
||||
// deliberately retain a strong reference to self
|
||||
Task {
|
||||
await self.doToggleReblog(visibility: visibility)
|
||||
await self.doToggleReblog()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
image = nil
|
||||
reblogVisibilityActions = []
|
||||
reblogVisibilityActions = nil
|
||||
}
|
||||
|
||||
let preview = ConfirmReblogStatusPreviewView(status: status)
|
||||
|
@ -62,11 +59,11 @@ class ReblogService {
|
|||
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: {
|
||||
// deliberately retain a strong reference to self
|
||||
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)
|
||||
menuAction.isSecondaryMenu = true
|
||||
config.actions.append(menuAction)
|
||||
|
@ -75,7 +72,7 @@ class ReblogService {
|
|||
presenter.present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func doToggleReblog(visibility: Visibility?) async {
|
||||
private func doToggleReblog() async {
|
||||
let oldValue = status.reblogged
|
||||
status.reblogged.toggle()
|
||||
mastodonController.persistentContainer.statusSubject.send(status.id)
|
||||
|
|
|
@ -22,9 +22,7 @@ class ToggleFollowHashtagService {
|
|||
self.presenter = presenter
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func toggleFollow() async -> Bool {
|
||||
let success: Bool
|
||||
func toggleFollow() async {
|
||||
let context = mastodonController.persistentContainer.viewContext
|
||||
var config: ToastConfiguration
|
||||
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtagName }) {
|
||||
|
@ -38,14 +36,11 @@ class ToggleFollowHashtagService {
|
|||
config = ToastConfiguration(title: "Unfollowed Hashtag")
|
||||
config.systemImageName = "checkmark"
|
||||
config.dismissAutomaticallyAfter = 2
|
||||
|
||||
success = true
|
||||
} catch {
|
||||
config = ToastConfiguration(from: error, with: "Error Unfollowing Hashtag", in: presenter) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.toggleFollow()
|
||||
}
|
||||
success = false
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
|
@ -58,19 +53,15 @@ class ToggleFollowHashtagService {
|
|||
config = ToastConfiguration(title: "Followed Hashtag")
|
||||
config.systemImageName = "checkmark"
|
||||
config.dismissAutomaticallyAfter = 2
|
||||
|
||||
success = true
|
||||
} catch {
|
||||
config = ToastConfiguration(from: error, with: "Error Following Hashtag", in: presenter) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.toggleFollow()
|
||||
}
|
||||
success = false
|
||||
}
|
||||
}
|
||||
presenter.showToast(configuration: config, animated: true)
|
||||
mastodonController.persistentContainer.save(context: context)
|
||||
return success
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -115,8 +115,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
}
|
||||
|
||||
if let clazz = NSClassFromString("SentryInstallation"),
|
||||
let objClazz = clazz as AnyObject as? NSObject,
|
||||
let id = objClazz.value(forKey: "id") as? String {
|
||||
let objClazz = clazz as AnyObject as? NSObjectProtocol,
|
||||
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)")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,8 +25,6 @@ public final class AccountMO: NSManagedObject, AccountProtocol {
|
|||
}
|
||||
|
||||
@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 botCD: Bool
|
||||
@NSManaged public var createdAt: Date
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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? {
|
||||
let context = context ?? viewContext
|
||||
let request: NSFetchRequest<RelationshipMO> = RelationshipMO.fetchRequest()
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
<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">
|
||||
<attribute name="acct" attributeType="String"/>
|
||||
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
|
||||
<attribute name="avatar" optional="YES" attributeType="URI"/>
|
||||
<attribute name="botCD" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
|
||||
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
|
||||
|
@ -34,12 +33,6 @@
|
|||
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
|
||||
</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">
|
||||
<attribute name="action" attributeType="String" defaultValueString="warn"/>
|
||||
<attribute name="context" attributeType="String"/>
|
||||
|
@ -145,6 +138,7 @@
|
|||
<memberEntity name="TimelinePosition"/>
|
||||
</configuration>
|
||||
<configuration name="Local">
|
||||
<memberEntity name="Account"/>
|
||||
<memberEntity name="Filter"/>
|
||||
<memberEntity name="FilterKeyword"/>
|
||||
<memberEntity name="FollowedHashtag"/>
|
||||
|
@ -152,7 +146,5 @@
|
|||
<memberEntity name="Status"/>
|
||||
<memberEntity name="TimelineState"/>
|
||||
<memberEntity name="List"/>
|
||||
<memberEntity name="Account"/>
|
||||
<memberEntity name="ActiveInstance"/>
|
||||
</configuration>
|
||||
</model>
|
|
@ -23,12 +23,11 @@ protocol ComposeHostingControllerDelegate: AnyObject {
|
|||
class ComposeHostingController: UIHostingController<ComposeHostingController.View>, DuckableViewController {
|
||||
|
||||
weak var delegate: ComposeHostingControllerDelegate?
|
||||
weak var duckableDelegate: DuckableViewControllerDelegate?
|
||||
|
||||
let controller: ComposeController
|
||||
let mastodonController: MastodonController
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private var assetPickerCompletion: (@MainActor ([PHPickerResult]) -> Void)?
|
||||
private var drawingCompletion: ((PKDrawing) -> Void)?
|
||||
|
||||
|
@ -46,6 +45,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
replyContentView: { AnyView(ComposeReplyContentView(status: $0, mastodonController: mastodonController, heightChanged: $1)) },
|
||||
emojiImageView: { AnyView(CustomEmojiImageView(emoji: $0)) }
|
||||
)
|
||||
controller.currentAccount = mastodonController.account
|
||||
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
|
@ -59,12 +59,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
|
||||
// set an initial title immediately, in case we're starting ducked
|
||||
self.navigationItem.title = self.controller.navigationTitle
|
||||
|
||||
mastodonController.$account
|
||||
.sink { [unowned self] in
|
||||
self.controller.currentAccount = $0
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
|
@ -113,6 +107,7 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
|
|||
return
|
||||
} else {
|
||||
dismiss(animated: true)
|
||||
duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -147,8 +147,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
|||
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
|
||||
}
|
||||
snapshot.appendItems(parentItems, toSection: .ancestors)
|
||||
// don't need to reconfigure main item, since when the refreshed copy was loaded
|
||||
// it would have triggered a reconfigure via the status observer
|
||||
snapshot.reconfigureItems([mainStatusItem])
|
||||
|
||||
// convert sub-threads into items for section and add to snapshot
|
||||
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
|
||||
|
|
|
@ -57,6 +57,10 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
dataSource = createDataSource()
|
||||
applyInitialSnapshot()
|
||||
|
||||
if mastodonController.instance == nil {
|
||||
mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
|
||||
}
|
||||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController!
|
||||
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(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
||||
mastodonController.instanceFeatures.featuresUpdated
|
||||
.sink { [unowned self] in self.instanceFeaturesChanged() }
|
||||
.store(in: &cancellables)
|
||||
mastodonController.$lists
|
||||
.sink { [unowned self] in self.reloadLists($0) }
|
||||
.store(in: &cancellables)
|
||||
|
@ -193,7 +194,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
snapshot.appendItems([.trends], toSection: .discover)
|
||||
}
|
||||
|
||||
private func instanceFeaturesChanged() {
|
||||
private func ownInstanceLoaded(_ instance: Instance) {
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
if mastodonController.instanceFeatures.trends,
|
||||
!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) {
|
||||
let context = mastodonController.persistentContainer.viewContext
|
||||
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? {
|
||||
var actions = [UIContextualAction]()
|
||||
let title: String
|
||||
let handler: UIContextualAction.Handler
|
||||
switch dataSource.itemIdentifier(for: indexPath) {
|
||||
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)
|
||||
}))
|
||||
}
|
||||
|
||||
case let .savedHashtag(hashtag):
|
||||
let name = hashtag.name.lowercased()
|
||||
let context = mastodonController.persistentContainer.viewContext
|
||||
let existing = try? context.fetch(SavedHashtag.fetchRequest(name: name, account: mastodonController.accountInfo!)).first
|
||||
if let existing {
|
||||
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)
|
||||
}
|
||||
}))
|
||||
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
|
||||
handler = { (_, _, completion) in
|
||||
self.removeSavedHashtag(hashtag)
|
||||
completion(true)
|
||||
}
|
||||
|
||||
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)
|
||||
completion(true)
|
||||
}))
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return UISwipeActionsConfiguration(actions: actions)
|
||||
|
||||
return UISwipeActionsConfiguration(actions: [
|
||||
UIContextualAction(style: .destructive, title: title, handler: handler)
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Collection View Delegate
|
||||
|
@ -581,7 +582,3 @@ extension ExploreViewController: UICollectionViewDragDelegate {
|
|||
return [UIDragItem(itemProvider: provider)]
|
||||
}
|
||||
}
|
||||
|
||||
extension ExploreViewController: TuskerNavigationDelegate {
|
||||
var apiController: MastodonController! { mastodonController }
|
||||
}
|
||||
|
|
|
@ -97,6 +97,10 @@ class MainSidebarViewController: UIViewController {
|
|||
|
||||
applyInitialSnapshot()
|
||||
|
||||
if mastodonController.instance == nil {
|
||||
mastodonController.getOwnInstance(completion: self.ownInstanceLoaded(_:))
|
||||
}
|
||||
|
||||
select(item: .tab(.timelines), animated: false)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
|
||||
|
@ -185,6 +189,14 @@ class MainSidebarViewController: UIViewController {
|
|||
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) {
|
||||
if let selectedItem,
|
||||
case .list(let list) = selectedItem,
|
||||
|
|
|
@ -225,7 +225,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
|||
|
||||
case let .tab(tab):
|
||||
// sidebar items that map 1 <-> 1 can be transferred directly
|
||||
tabBarViewController.select(tab: tab, dismissPresented: false)
|
||||
tabBarViewController.select(tab: tab)
|
||||
|
||||
case .explore:
|
||||
// Search sidebar item maps to the Explore tab with the search controller/results visible
|
||||
|
@ -247,7 +247,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
|||
explore.loadViewIfNeeded()
|
||||
|
||||
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.
|
||||
let query = search.searchController.searchBar.text ?? ""
|
||||
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
|
||||
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(_):
|
||||
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
|
||||
// in compact mode and performing a search.
|
||||
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
|
||||
|
|
|
@ -111,13 +111,13 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
|||
repositionFastSwitcherIndicator()
|
||||
}
|
||||
|
||||
func select(tab: Tab, dismissPresented: Bool) {
|
||||
func select(tab: Tab) {
|
||||
if tab == .compose {
|
||||
compose(editing: nil)
|
||||
} else {
|
||||
// when switching tabs, dismiss the currently presented VC
|
||||
// otherwise the selected tab changes behind the presented VC
|
||||
if presentedViewController != nil && dismissPresented {
|
||||
if presentedViewController != nil {
|
||||
dismiss(animated: true) {
|
||||
self.selectedIndex = tab.rawValue
|
||||
}
|
||||
|
@ -141,8 +141,8 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
|||
return
|
||||
}
|
||||
NSLayoutConstraint.deactivate(fastSwitcherConstraints)
|
||||
let isPortrait = view.bounds.width < view.bounds.height
|
||||
if traitCollection.horizontalSizeClass == .compact && isPortrait {
|
||||
// using interfaceOrientation isn't ideal, but UITabBar buttons may lay out horizontally even in the compact size class
|
||||
if traitCollection.horizontalSizeClass == .compact && interfaceOrientation.isPortrait {
|
||||
fastSwitcherConstraints = [
|
||||
fastSwitcherIndicator.centerYAnchor.constraint(equalTo: myProfileButton.centerYAnchor, constant: -4),
|
||||
// tab bar button image width is 30
|
||||
|
@ -291,18 +291,18 @@ extension MainTabBarViewController: TuskerRootViewController {
|
|||
func select(route: TuskerRoute, animated: Bool) {
|
||||
switch route {
|
||||
case .timelines:
|
||||
select(tab: .timelines, dismissPresented: true)
|
||||
select(tab: .timelines)
|
||||
case .notifications:
|
||||
select(tab: .notifications, dismissPresented: true)
|
||||
select(tab: .notifications)
|
||||
case .myProfile:
|
||||
select(tab: .myProfile, dismissPresented: true)
|
||||
select(tab: .myProfile)
|
||||
case .explore:
|
||||
select(tab: .explore, dismissPresented: true)
|
||||
select(tab: .explore)
|
||||
case .bookmarks:
|
||||
select(tab: .explore, dismissPresented: true)
|
||||
select(tab: .explore)
|
||||
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
|
||||
case .list(id: let id):
|
||||
select(tab: .explore, dismissPresented: true)
|
||||
select(tab: .explore)
|
||||
if let list = mastodonController.getCachedList(id: id) {
|
||||
let nav = getNavigationController()
|
||||
_ = nav.popToRootViewController(animated: animated)
|
||||
|
@ -325,7 +325,7 @@ extension MainTabBarViewController: TuskerRootViewController {
|
|||
return
|
||||
}
|
||||
|
||||
select(tab: .explore, dismissPresented: true)
|
||||
select(tab: .explore)
|
||||
exploreNavController.popToRootViewController(animated: false)
|
||||
|
||||
// setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time
|
||||
|
|
|
@ -48,7 +48,11 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
$0.axis = .horizontal
|
||||
$0.alignment = .fill
|
||||
$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 {
|
||||
|
@ -142,7 +146,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
|
||||
let imageView = CachedImageView(cache: .avatars)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
|
@ -236,9 +240,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
|
||||
override var accessibilityLabel: String? {
|
||||
get {
|
||||
guard let first = group.notifications.first else {
|
||||
return nil
|
||||
}
|
||||
let first = group.notifications.first!
|
||||
var str = ""
|
||||
switch group.kind {
|
||||
case .favourite:
|
||||
|
|
|
@ -45,7 +45,9 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$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 {
|
||||
|
@ -119,7 +121,6 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
|
|||
avatarStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
for avatarURL in people.lazy.compactMap(\.avatar).prefix(10) {
|
||||
let imageView = CachedImageView(cache: .avatars)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.layer.masksToBounds = true
|
||||
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
|
|
|
@ -20,7 +20,6 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
|
|||
}
|
||||
|
||||
private let avatarImageView = CachedImageView(cache: .avatars).configure {
|
||||
$0.contentMode = .scaleAspectFill
|
||||
$0.layer.masksToBounds = true
|
||||
$0.layer.cornerCurve = .continuous
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -48,7 +47,9 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewListCell {
|
|||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$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 {
|
||||
|
|
|
@ -263,9 +263,6 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
|||
return
|
||||
}
|
||||
var snapshot = dataSource.snapshot()
|
||||
guard snapshot.sectionIdentifiers.contains(.notifications) else {
|
||||
return
|
||||
}
|
||||
let items = snapshot.itemIdentifiers(inSection: .notifications)
|
||||
let toDelete = statusIDs.flatMap { id in
|
||||
items.lazy.filter { $0.group?.notifications.first?.status?.id == id }
|
||||
|
@ -398,19 +395,13 @@ extension NotificationsCollectionViewController {
|
|||
var types = Set(Notification.Kind.allCases)
|
||||
types.remove(.unknown)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] {
|
||||
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")
|
||||
crumb.data = [
|
||||
"id": notif.id,
|
||||
|
|
|
@ -91,6 +91,9 @@ class PollFinishedNotificationCollectionViewCell: UICollectionViewListCell {
|
|||
contentView.addSubview(iconView)
|
||||
vStack.translatesAutoresizingMaskIntoConstraints = false
|
||||
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([
|
||||
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
|
||||
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.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
|
||||
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)
|
||||
|
|
|
@ -150,7 +150,7 @@ class OnboardingViewController: UINavigationController {
|
|||
mastodonController.accountInfo = tempAccountInfo
|
||||
|
||||
updateStatus("Checking Credentials")
|
||||
let ownAccount: AccountMO
|
||||
let ownAccount: Account
|
||||
do {
|
||||
ownAccount = try await retrying("Getting own account") {
|
||||
try await mastodonController.getOwnAccount()
|
||||
|
|
|
@ -77,11 +77,7 @@ struct TipJarView: View {
|
|||
}
|
||||
}
|
||||
.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 {
|
||||
|
|
|
@ -31,7 +31,7 @@ struct ReportSelectRulesView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
List(mastodonController.instance!.rules!) { rule in
|
||||
List(mastodonController.instance.rules!) { rule in
|
||||
Button {
|
||||
if selectedRuleIDs.contains(rule.id) {
|
||||
selectedRuleIDs.removeAll(where: { $0 == rule.id })
|
||||
|
|
|
@ -24,6 +24,9 @@ struct ReportView: View {
|
|||
self.account = mastodonController.persistentContainer.account(for: report.accountID)!
|
||||
self.mastodonController = mastodonController
|
||||
self._report = StateObject(wrappedValue: report)
|
||||
if mastodonController.instance?.rules == nil {
|
||||
report.reason = .spam
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
|
@ -31,7 +31,7 @@ class StatusEditPollView: UIStackView, StatusContentPollView {
|
|||
|
||||
for option in poll?.options ?? [] {
|
||||
// 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
|
||||
let label = EmojiLabel()
|
||||
label.text = option.title
|
||||
|
|
|
@ -518,7 +518,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
if let centerStatusID,
|
||||
let index = statusIDs.firstIndex(of: centerStatusID) {
|
||||
self.scrollToItem(item: items[index])
|
||||
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
|
||||
} else {
|
||||
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
|
||||
}
|
||||
|
|
|
@ -486,7 +486,6 @@ class CustomAlertPresentationAnimation: NSObject, UIViewControllerAnimatedTransi
|
|||
|
||||
let container = transitionContext.containerView
|
||||
container.addSubview(alert.view)
|
||||
alert.view.frame = container.bounds
|
||||
|
||||
guard transitionContext.isAnimated else {
|
||||
presenter.view.tintAdjustmentMode = .dimmed
|
||||
|
|
|
@ -63,20 +63,19 @@ class TimelineLikeController<Item: Sendable> {
|
|||
}
|
||||
let token = LoadAttemptToken()
|
||||
state = .loadingInitial(token, hasAddedLoadingIndicator: false)
|
||||
await emit(event: .addLoadingIndicator)
|
||||
state = .loadingInitial(token, hasAddedLoadingIndicator: true)
|
||||
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
|
||||
do {
|
||||
let items = try await delegate.loadInitial()
|
||||
guard case .loadingInitial(token, _) = state else {
|
||||
return
|
||||
}
|
||||
await loadingIndicator.end()
|
||||
await emit(event: .replaceAllItems(items, token))
|
||||
await emit(event: .removeLoadingIndicator)
|
||||
state = .idle
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
await emit(event: .removeLoadingIndicator)
|
||||
await loadingIndicator.end()
|
||||
await emit(event: .loadAllError(error, token))
|
||||
state = .notLoadedInitial
|
||||
}
|
||||
|
@ -89,10 +88,9 @@ class TimelineLikeController<Item: Sendable> {
|
|||
}
|
||||
let token = LoadAttemptToken()
|
||||
state = .restoringInitial(token, hasAddedLoadingIndicator: false)
|
||||
await emit(event: .addLoadingIndicator)
|
||||
state = .restoringInitial(token, hasAddedLoadingIndicator: true)
|
||||
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .restoringInitial(token, hasAddedLoadingIndicator: true))
|
||||
await doRestore()
|
||||
await emit(event: .removeLoadingIndicator)
|
||||
await loadingIndicator.end()
|
||||
state = .idle
|
||||
}
|
||||
|
||||
|
@ -130,20 +128,19 @@ class TimelineLikeController<Item: Sendable> {
|
|||
return
|
||||
}
|
||||
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
|
||||
await emit(event: .addLoadingIndicator)
|
||||
state = .loadingOlder(token, hasAddedLoadingIndicator: true)
|
||||
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||
do {
|
||||
let items = try await delegate.loadOlder()
|
||||
guard case .loadingOlder(token, _) = state else {
|
||||
return
|
||||
}
|
||||
await loadingIndicator.end()
|
||||
await emit(event: .appendItems(items, token))
|
||||
await emit(event: .removeLoadingIndicator)
|
||||
state = .idle
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
await emit(event: .removeLoadingIndicator)
|
||||
await loadingIndicator.end()
|
||||
await emit(event: .loadOlderError(error, token))
|
||||
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 {
|
||||
|
|
|
@ -101,8 +101,7 @@ class AttachmentsContainerView: UIView {
|
|||
accessibilityElements.append(attachmentView)
|
||||
if Preferences.shared.showUncroppedMediaInline,
|
||||
let attachmentAspectRatio = attachmentView.attachmentAspectRatio {
|
||||
// clamp to prevent excessively short/tall attachments
|
||||
aspectRatio = max(min(attachmentAspectRatio, 2/1), 1/2)
|
||||
aspectRatio = attachmentAspectRatio
|
||||
}
|
||||
case 2:
|
||||
let left = createAttachmentView(index: 0, hSize: .half, vSize: .full)
|
||||
|
|
|
@ -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() {
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
updateImage()
|
||||
|
|
|
@ -75,10 +75,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
textContainerInset = .zero
|
||||
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
|
||||
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
|
||||
addGestureRecognizer(recognizer)
|
||||
|
|
|
@ -9,8 +9,6 @@
|
|||
import UIKit
|
||||
|
||||
class PollOptionCheckboxView: UIView {
|
||||
|
||||
private static let size: CGFloat = 20
|
||||
|
||||
var isChecked: Bool = false {
|
||||
didSet {
|
||||
|
@ -27,19 +25,16 @@ class PollOptionCheckboxView: UIView {
|
|||
updateStyle()
|
||||
}
|
||||
}
|
||||
var multiple: Bool = false {
|
||||
didSet {
|
||||
updateStyle()
|
||||
}
|
||||
}
|
||||
|
||||
private let imageView: UIImageView
|
||||
|
||||
init() {
|
||||
init(multiple: Bool) {
|
||||
imageView = UIImageView(image: UIImage(systemName: "checkmark")!)
|
||||
|
||||
super.init(frame: .zero)
|
||||
|
||||
let size: CGFloat = 20
|
||||
layer.cornerRadius = (multiple ? 0.1 : 0.5) * size
|
||||
layer.cornerCurve = .continuous
|
||||
layer.borderWidth = 2
|
||||
|
||||
|
@ -51,7 +46,7 @@ class PollOptionCheckboxView: UIView {
|
|||
|
||||
NSLayoutConstraint.activate([
|
||||
widthAnchor.constraint(equalTo: heightAnchor),
|
||||
widthAnchor.constraint(equalToConstant: PollOptionCheckboxView.size),
|
||||
widthAnchor.constraint(equalToConstant: size),
|
||||
|
||||
imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3),
|
||||
imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3),
|
||||
|
@ -69,8 +64,6 @@ class PollOptionCheckboxView: UIView {
|
|||
}
|
||||
|
||||
private func updateStyle() {
|
||||
layer.cornerRadius = (multiple ? 0.1 : 0.5) * PollOptionCheckboxView.size
|
||||
|
||||
imageView.isHidden = !isChecked
|
||||
if voted || readOnly {
|
||||
layer.borderColor = UIColor.clear.cgColor
|
||||
|
|
|
@ -11,43 +11,35 @@ import Pachyderm
|
|||
|
||||
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(set) var label: EmojiLabel!
|
||||
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
|
||||
$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?
|
||||
private(set) var checkbox: PollOptionCheckboxView?
|
||||
|
||||
init() {
|
||||
init(poll: Poll, option: Poll.Option, mastodonController: MastodonController) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
layer.cornerRadius = PollOptionView.cornerRadius
|
||||
let minHeight: CGFloat = 35
|
||||
layer.cornerRadius = 0.1 * minHeight
|
||||
layer.cornerCurve = .continuous
|
||||
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.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.numberOfLines = 0
|
||||
label.font = .preferredFont(forTextStyle: .callout)
|
||||
label.text = option.title
|
||||
label.setEmojis(poll.emojis, identifier: poll.id)
|
||||
addSubview(label)
|
||||
labelLeadingToSelfConstraint = label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8)
|
||||
|
||||
percentLabel = UILabel()
|
||||
let percentLabel = UILabel()
|
||||
percentLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
percentLabel.text = "100%"
|
||||
percentLabel.font = label.font
|
||||
|
@ -56,48 +48,6 @@ class PollOptionView: UIView {
|
|||
percentLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
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
|
||||
|
||||
if (poll.voted ?? false) || poll.effectiveExpired,
|
||||
|
@ -111,27 +61,60 @@ class PollOptionView: UIView {
|
|||
frac = poll.votesCount == 0 ? 0 : CGFloat(optionVotes) / CGFloat(poll.votesCount)
|
||||
}
|
||||
let percent = String(format: "%.0f%%", frac * 100)
|
||||
|
||||
|
||||
percentLabel.isHidden = false
|
||||
percentLabel.text = percent
|
||||
|
||||
if fillView.superview != self {
|
||||
addSubview(fillView)
|
||||
NSLayoutConstraint.activate([
|
||||
fillView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
fillView.topAnchor.constraint(equalTo: topAnchor),
|
||||
fillView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
fillViewWidthConstraint?.isActive = false
|
||||
fillViewWidthConstraint = fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac)
|
||||
fillViewWidthConstraint!.isActive = true
|
||||
|
||||
|
||||
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)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
fillView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
fillView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: frac),
|
||||
fillView.topAnchor.constraint(equalTo: topAnchor),
|
||||
fillView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ class PollOptionsView: UIControl {
|
|||
var mastodonController: MastodonController!
|
||||
|
||||
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)?
|
||||
|
||||
|
@ -32,15 +32,10 @@ class PollOptionsView: UIControl {
|
|||
|
||||
override var isEnabled: Bool {
|
||||
didSet {
|
||||
options.forEach { $0.checkboxIfInitialized?.readOnly = !isEnabled }
|
||||
options.forEach { $0.checkbox?.readOnly = !isEnabled }
|
||||
}
|
||||
}
|
||||
|
||||
override var accessibilityElements: [Any]? {
|
||||
get { options }
|
||||
set {}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
stack = UIStackView()
|
||||
|
||||
|
@ -66,22 +61,20 @@ class PollOptionsView: UIControl {
|
|||
func updateUI(poll: Poll) {
|
||||
self.poll = poll
|
||||
|
||||
if poll.options.count > options.count {
|
||||
for _ in 0..<(poll.options.count - options.count) {
|
||||
let optView = PollOptionView()
|
||||
options.append(optView)
|
||||
stack.addArrangedSubview(optView)
|
||||
}
|
||||
} else if poll.options.count < options.count {
|
||||
for _ in 0..<(options.count - poll.options.count) {
|
||||
options.removeLast().removeFromSuperview()
|
||||
options.forEach { $0.removeFromSuperview() }
|
||||
|
||||
options = poll.options.enumerated().map { (index, opt) in
|
||||
let optionView = PollOptionView(poll: poll, option: opt, mastodonController: mastodonController)
|
||||
if let checkbox = optionView.checkbox {
|
||||
checkbox.readOnly = !isEnabled
|
||||
checkbox.isChecked = poll.ownVotes?.contains(index) ?? false
|
||||
checkbox.voted = poll.voted ?? false
|
||||
}
|
||||
stack.addArrangedSubview(optionView)
|
||||
return optionView
|
||||
}
|
||||
|
||||
for (index, (view, opt)) in zip(options, poll.options).enumerated() {
|
||||
view.updateUI(poll: poll, option: opt, ownVoted: poll.ownVotes?.contains(index) ?? false, mastodonController: mastodonController)
|
||||
view.checkboxIfInitialized?.readOnly = !isEnabled
|
||||
}
|
||||
accessibilityElements = options
|
||||
}
|
||||
|
||||
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
|
||||
|
@ -96,13 +89,13 @@ class PollOptionsView: UIControl {
|
|||
|
||||
private func selectOption(_ option: PollOptionView) {
|
||||
if poll.multiple {
|
||||
option.checkboxIfInitialized?.isChecked.toggle()
|
||||
option.checkbox?.isChecked.toggle()
|
||||
} else {
|
||||
for opt in options {
|
||||
if opt === option {
|
||||
opt.checkboxIfInitialized?.isChecked = true
|
||||
opt.checkbox?.isChecked = true
|
||||
} 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? {
|
||||
// don't let subviews receive touch events
|
||||
if isEnabled {
|
||||
return self
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
// MARK: - UIControl
|
||||
|
|
|
@ -32,12 +32,10 @@ class PollVoteButton: UIView {
|
|||
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setTitleColor(.secondaryLabel, for: .disabled)
|
||||
button.contentHorizontalAlignment = .trailing
|
||||
embedSubview(button)
|
||||
#if targetEnvironment(macCatalyst)
|
||||
label.textColor = .secondaryLabel
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textAlignment = .right
|
||||
embedSubview(label)
|
||||
#endif
|
||||
|
||||
|
|
|
@ -67,7 +67,6 @@ class StatusPollView: UIView, StatusContentPollView {
|
|||
voteButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
voteButton.addTarget(self, action: #selector(votePressed))
|
||||
voteButton.setFont(infoLabel.font)
|
||||
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
addSubview(voteButton)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -78,7 +77,7 @@ class StatusPollView: UIView, StatusContentPollView {
|
|||
infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
infoLabel.topAnchor.constraint(equalTo: optionsView.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.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
|
@ -142,7 +141,7 @@ class StatusPollView: UIView, StatusContentPollView {
|
|||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ class ProfileHeaderButton: UIButton {
|
|||
var backgroundConfig = UIBackgroundConfiguration.clear()
|
||||
backgroundConfig.visualEffect = UIBlurEffect(style: .systemThickMaterial)
|
||||
config.background = backgroundConfig
|
||||
config.imagePadding = 4
|
||||
self.configuration = config
|
||||
}
|
||||
|
||||
|
|
|
@ -75,13 +75,6 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
}
|
||||
|
||||
private func createOptionViews() {
|
||||
optionsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
|
|
|
@ -28,7 +28,6 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
|
||||
private static let avatarImageViewSize: CGFloat = 50
|
||||
private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure {
|
||||
$0.contentMode = .scaleAspectFill
|
||||
$0.layer.masksToBounds = true
|
||||
$0.layer.cornerCurve = .continuous
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -141,23 +140,12 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
$0.isPointerInteractionEnabled = true
|
||||
}
|
||||
|
||||
// using a UIStackView for this does not layout correctly on the first pass
|
||||
// (everything is shifted slightly to the right for some reason)
|
||||
// so do it manually, since there are only two subvviews
|
||||
private lazy var actionsCountHStack = UIView().configure {
|
||||
reblogsCountButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
$0.addSubview(reblogsCountButton)
|
||||
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 lazy var actionsCountHStack = UIStackView(arrangedSubviews: [
|
||||
reblogsCountButton,
|
||||
favoritesCountButton,
|
||||
]).configure {
|
||||
$0.axis = .horizontal
|
||||
$0.spacing = 8
|
||||
}
|
||||
|
||||
private let timestampAndClientLabel = UILabel().configure {
|
||||
|
@ -338,12 +326,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
|||
}
|
||||
|
||||
override func updateConfiguration(using state: UICellConfigurationState) {
|
||||
var config = UIBackgroundConfiguration.listPlainCell().updated(for: state)
|
||||
// conv main status isn't selectable
|
||||
if !state.isFocused {
|
||||
config.backgroundColor = .appBackground
|
||||
}
|
||||
backgroundConfiguration = config
|
||||
backgroundConfiguration = .appListPlainCell(for: state)
|
||||
}
|
||||
|
||||
// MARK: Configure UI
|
||||
|
|
|
@ -30,7 +30,7 @@ class StatusCardView: UIView {
|
|||
private var titleLabel: UILabel!
|
||||
private var descriptionLabel: UILabel!
|
||||
private var domainLabel: UILabel!
|
||||
private var imageView: StatusCardImageView!
|
||||
private var imageView: CachedImageView!
|
||||
private var placeholderImageView: UIImageView!
|
||||
private var leadingSpacer: UIView!
|
||||
private var trailingSpacer: UIView!
|
||||
|
@ -80,7 +80,7 @@ class StatusCardView: UIView {
|
|||
vStack.alignment = .leading
|
||||
vStack.spacing = 0
|
||||
|
||||
imageView = StatusCardImageView(cache: .attachments)
|
||||
imageView = CachedImageView(cache: .attachments)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
|
||||
|
@ -167,18 +167,7 @@ class StatusCardView: UIView {
|
|||
}
|
||||
|
||||
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
|
||||
leadingSpacer.isHidden = true
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -60,8 +60,7 @@ extension StatusCollectionViewCell {
|
|||
.receive(on: DispatchQueue.main)
|
||||
.filter { [unowned self] in $0 == self.statusID }
|
||||
.sink { [unowned self] _ in
|
||||
if let mastodonController = self.mastodonController,
|
||||
let status = mastodonController.persistentContainer.status(for: self.statusID) {
|
||||
if let status = self.mastodonController.persistentContainer.status(for: self.statusID) {
|
||||
// update immediately w/o animation
|
||||
self.favoriteButton.active = status.favourited
|
||||
self.reblogButton.active = status.reblogged
|
||||
|
@ -125,19 +124,7 @@ extension StatusCollectionViewCell {
|
|||
statusState.collapsed = false
|
||||
}
|
||||
}
|
||||
let expected = !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
|
||||
}
|
||||
}
|
||||
collapseButton.isHidden = !statusState.collapsible!
|
||||
contentContainer.setCollapsed(statusState.collapsed!)
|
||||
if statusState.collapsed! {
|
||||
contentContainer.alpha = 0
|
||||
|
|
|
@ -50,9 +50,8 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
|
||||
private var isHiddenObservations: [NSKeyValueObservation] = []
|
||||
|
||||
private var visibleSubviews = IndexSet()
|
||||
private var verticalConstraints: [NSLayoutConstraint] = []
|
||||
private var lastSubviewBottomConstraint: (UIView, NSLayoutConstraint)?
|
||||
private var lastSubviewBottomConstraint: NSLayoutConstraint?
|
||||
private var zeroHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
private var isCollapsed = false
|
||||
|
@ -94,36 +93,32 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
}
|
||||
|
||||
override func updateConstraints() {
|
||||
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
|
||||
if self.visibleSubviews != visibleSubviews {
|
||||
self.visibleSubviews = visibleSubviews
|
||||
NSLayoutConstraint.deactivate(verticalConstraints)
|
||||
verticalConstraints = []
|
||||
var lastVisibleSubview: UIView?
|
||||
|
||||
for subviewIndex in visibleSubviews {
|
||||
let subview = arrangedSubviews[subviewIndex]
|
||||
if let lastVisibleSubview {
|
||||
verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4))
|
||||
} else {
|
||||
verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor))
|
||||
}
|
||||
lastVisibleSubview = subview
|
||||
NSLayoutConstraint.deactivate(verticalConstraints)
|
||||
verticalConstraints = []
|
||||
var lastVisibleSubview: UIView?
|
||||
|
||||
for subview in arrangedSubviews {
|
||||
guard !subview.isHidden else {
|
||||
continue
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate(verticalConstraints)
|
||||
}
|
||||
if let lastVisibleSubview {
|
||||
verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4))
|
||||
} else {
|
||||
verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor))
|
||||
}
|
||||
|
||||
if lastSubviewBottomConstraint == nil || arrangedSubviews[visibleSubviews.last!] !== lastSubviewBottomConstraint?.0 {
|
||||
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
|
||||
let lastVisibleSubview = arrangedSubviews[visibleSubviews.last!]
|
||||
let constraint = lastVisibleSubview.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
constraint.isActive = !isCollapsed
|
||||
constraint.priority = .defaultLow
|
||||
lastSubviewBottomConstraint = (lastVisibleSubview, constraint)
|
||||
lastVisibleSubview = subview
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate(verticalConstraints)
|
||||
|
||||
lastSubviewBottomConstraint?.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
|
||||
lastSubviewBottomConstraint = subviews.last(where: { !$0.isHidden })!.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
lastSubviewBottomConstraint!.isActive = !isCollapsed
|
||||
lastSubviewBottomConstraint!.priority = .defaultLow
|
||||
|
||||
zeroHeightConstraint.isActive = isCollapsed
|
||||
|
||||
super.updateConstraints()
|
||||
|
@ -138,7 +133,7 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
|
|||
// 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 let lastSubviewBottomConstraint {
|
||||
lastSubviewBottomConstraint.1.isActive = !collapsed
|
||||
lastSubviewBottomConstraint.isActive = !collapsed
|
||||
zeroHeightConstraint.isActive = collapsed
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,11 +35,9 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
$0.layer.masksToBounds = true
|
||||
$0.layer.cornerCurve = .continuous
|
||||
$0.tintColor = .secondaryLabel
|
||||
let heightConstraint = $0.heightAnchor.constraint(equalToConstant: TimelineStatusCollectionViewCell.timelineReasonIconSize)
|
||||
heightConstraint.identifier = "TimelineReason-Height"
|
||||
NSLayoutConstraint.activate([
|
||||
// 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),
|
||||
])
|
||||
}
|
||||
|
@ -62,26 +60,21 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
$0.addSubview(contentVStack)
|
||||
metaIndicatorsView.translatesAutoresizingMaskIntoConstraints = false
|
||||
$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([
|
||||
avatarImageView.leadingAnchor.constraint(equalTo: $0.leadingAnchor),
|
||||
avatarTopConstraint,
|
||||
avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor),
|
||||
contentVStack.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: 8),
|
||||
contentVStack.trailingAnchor.constraint(equalTo: $0.trailingAnchor),
|
||||
contentVStack.topAnchor.constraint(equalTo: $0.topAnchor),
|
||||
contentVStack.bottomAnchor.constraint(equalTo: $0.bottomAnchor),
|
||||
metaIndicatorsView.leadingAnchor.constraint(greaterThanOrEqualTo: $0.leadingAnchor),
|
||||
metaIndicatorsView.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
|
||||
metaIndicatorsTopConstraint,
|
||||
metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4),
|
||||
])
|
||||
}
|
||||
|
||||
private static let avatarImageViewSize: CGFloat = 50
|
||||
private(set) lazy var avatarImageView = CachedImageView(cache: .avatars).configure {
|
||||
$0.contentMode = .scaleAspectFill
|
||||
$0.layer.masksToBounds = true
|
||||
$0.layer.cornerCurve = .continuous
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -183,7 +176,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
$0.tintAdjustmentMode = .normal
|
||||
$0.setContentHuggingPriority(.defaultHigh, for: .vertical)
|
||||
$0.addTarget(self, action: #selector(collapseButtonPressed), for: .touchUpInside)
|
||||
$0.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
}
|
||||
|
||||
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.identifier = "MainContainerTopToReblog"
|
||||
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)
|
||||
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([
|
||||
// why is this 4 but the mainContainerTopSelfConstraint constant 8? because this looks more balanced
|
||||
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.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16),
|
||||
mainContainerBottomToActionsConstraint,
|
||||
|
||||
actionsContainer.leadingAnchor.constraint(equalTo: statusContainer.leadingAnchor, constant: 16),
|
||||
actionsContainer.trailingAnchor.constraint(equalTo: statusContainer.trailingAnchor, constant: -16),
|
||||
// yes, this is deliberately 6. 4 looks to cramped, 8 looks uneven
|
||||
actionsContainer.bottomAnchor.constraint(equalTo: statusContainer.bottomAnchor, constant: -6),
|
||||
|
||||
metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6),
|
||||
metaIndicatorsBottomConstraint,
|
||||
])
|
||||
|
||||
updateActionsVisibility()
|
||||
|
@ -539,21 +535,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
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 {
|
||||
case .allow:
|
||||
setContentViewMode(.status)
|
||||
|
@ -564,10 +545,6 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
attrStr.append(showStr)
|
||||
filteredLabel.attributedText = attrStr
|
||||
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
|
||||
case .hide:
|
||||
fatalError("unreachable")
|
||||
|
@ -575,11 +552,20 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
|
||||
createObservers()
|
||||
|
||||
self.statusState = state
|
||||
|
||||
var hideTimelineReason = true
|
||||
|
||||
if let reblogStatus {
|
||||
if let rebloggedStatus = status.reblog {
|
||||
reblogStatusID = statusID
|
||||
rebloggerID = status.account.id
|
||||
|
||||
hideTimelineReason = false
|
||||
updateRebloggerLabel(reblogger: reblogStatus.account)
|
||||
updateRebloggerLabel(reblogger: status.account)
|
||||
status = rebloggedStatus
|
||||
} else {
|
||||
reblogStatusID = nil
|
||||
rebloggerID = nil
|
||||
}
|
||||
|
||||
if showFollowedHashtags {
|
||||
|
@ -593,14 +579,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
}
|
||||
|
||||
timelineReasonHStack.isHidden = hideTimelineReason
|
||||
// do this to make sure the currently active constraint is deactivated first
|
||||
if hideTimelineReason {
|
||||
mainContainerTopToReblogLabelConstraint.isActive = false
|
||||
mainContainerTopToSelfConstraint.isActive = true
|
||||
} else {
|
||||
mainContainerTopToSelfConstraint.isActive = false
|
||||
mainContainerTopToReblogLabelConstraint.isActive = true
|
||||
}
|
||||
mainContainerTopToReblogLabelConstraint.isActive = !hideTimelineReason
|
||||
mainContainerTopToSelfConstraint.isActive = hideTimelineReason
|
||||
|
||||
doUpdateUI(status: status, precomputedContent: precomputedContent)
|
||||
|
||||
|
@ -699,11 +679,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
}
|
||||
|
||||
private func updateActionsVisibility() {
|
||||
if Preferences.shared.hideActionsInTimeline && !actionsContainer.isHidden {
|
||||
if Preferences.shared.hideActionsInTimeline {
|
||||
actionsContainer.isHidden = true
|
||||
mainContainerBottomToActionsConstraint.isActive = false
|
||||
mainContainerBottomToSelfConstraint.isActive = true
|
||||
} else if !Preferences.shared.hideActionsInTimeline && actionsContainer.isHidden {
|
||||
mainContainerBottomToActionsConstraint.isActive = false
|
||||
} else {
|
||||
actionsContainer.isHidden = false
|
||||
mainContainerBottomToSelfConstraint.isActive = false
|
||||
mainContainerBottomToActionsConstraint.isActive = true
|
||||
|
@ -804,8 +784,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
|
|||
}
|
||||
|
||||
func contextMenuConfiguration() -> UIContextMenuConfiguration? {
|
||||
guard let mastodonController,
|
||||
let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
|
||||
return nil
|
||||
}
|
||||
return UIContextMenuConfiguration {
|
||||
|
|
|
@ -125,7 +125,7 @@ private func captureError(_ error: Client.Error, in mastodonController: Mastodon
|
|||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
switch mastodonController.instanceFeatures.instanceType {
|
||||
|
@ -153,8 +153,8 @@ private func captureError(_ error: Client.Error, in mastodonController: Mastodon
|
|||
event.tags!["instance_type"] = "pixelfed"
|
||||
case .gotosocial:
|
||||
event.tags!["instance_type"] = "gotosocial"
|
||||
case .firefish(let calckeyVersion):
|
||||
event.tags!["instance_type"] = "firefish"
|
||||
case .calckey(let calckeyVersion):
|
||||
event.tags!["instance_type"] = "calckey"
|
||||
if let calckeyVersion {
|
||||
event.tags!["calckey_version"] = calckeyVersion
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue