Compare commits

...

54 Commits

Author SHA1 Message Date
Shadowfacts 6c0564e0ee Bump version and update changelog 2023-07-23 10:36:11 -07:00
Shadowfacts 3d232d81ba Fix firefish instances not being detected 2023-07-22 11:23:16 -07:00
Shadowfacts 3109aafd20 Workaround for status collapse button overlapping other views in the cell 2023-07-18 21:14:43 -07:00
Shadowfacts 105a01811a Actual fix for links appearing as the wrong color
Closes #402
2023-07-18 21:01:30 -07:00
Shadowfacts 33999fe895 Fix crash/hang when showing emoji autocomplete with very many emojis
Closes #424
2023-07-13 23:41:02 -07:00
Shadowfacts 7f12479514 Fix not being able to share images from Shortcuts actions that have public.image and public.file-url representations
Closes #420
2023-07-08 15:37:45 -07:00
Shadowfacts 0eb000224e Fix double posting in poor network conditions
Closes #421
2023-07-08 15:24:40 -07:00
Shadowfacts 3c9692d5b2 Remove ambiguating constraint priorities, avoid removing and recreating the same constraints
Closes #407
2023-07-05 20:30:55 -07:00
Shadowfacts 50bfaf7236 Clamp uncropped attachment aspect ratio
Closes #418
2023-07-04 11:11:20 -07:00
Shadowfacts 385f31728d Fix sharing screenshot from markup not working
Closes #419
2023-07-04 11:07:35 -07:00
Shadowfacts bcd487d311 Fix favorites count button changing with when (un)faving
Closes #406
2023-07-04 10:25:32 -07:00
Shadowfacts 8f8e2a2aea Add unfollow hashtag action to Explore screen
Closes #417
2023-07-04 09:56:35 -07:00
Shadowfacts 54034ff727 Ignore HTTP 503 errors 2023-07-02 11:53:49 -07:00
Shadowfacts ee5db96c9e Workaround for links using the wrong tint color
Closes #402
2023-07-02 09:46:17 -07:00
Shadowfacts f825760fe9 Fix profile header follow button icon spacing 2023-06-26 22:18:27 -07:00
Shadowfacts a339884d1f Fix ScrollingSegmentedControl being cut off at smaller the default dynamic type size
Closes #410
2023-06-26 21:52:51 -07:00
Shadowfacts 1de586f907 Fix reblog with visibility not working 2023-06-26 21:41:43 -07:00
Shadowfacts bd162afdcc Fix showing incorrect visibilities in reblog confirmation alert 2023-06-26 21:40:43 -07:00
Shadowfacts 956b817045 Correct log level 2023-06-26 21:39:09 -07:00
Shadowfacts 28ee0908d7 Blur link card images when status is sensitive
Closes #412
2023-06-26 21:35:15 -07:00
Shadowfacts c3cf38b0c9 Fix not being able to refresh Mentions tab on Pleroma
Closes #411
2023-06-26 21:17:21 -07:00
Shadowfacts 7929e7530f Fix incorrect context menu preview on filtered post
Closes #413
2023-06-26 21:12:20 -07:00
Shadowfacts a11e453112 Fix reblog confirmation alert not being centered in non-fullscreen window
Closes #415
2023-06-26 21:01:23 -07:00
Shadowfacts 2e7ad1626e Fix avatars being squished in certain places
Closes #414
2023-06-26 20:47:38 -07:00
Shadowfacts 4182c15500 Fix invalid status notifications not being removed
Closes #416
2023-06-26 20:38:10 -07:00
Shadowfacts 4b43726e1d Fix not being able to follow hashtags on akkoma
Closes #408
2023-06-03 18:07:44 -07:00
Shadowfacts a4e7082ab8 Fix race condition in Compose screen when loading account 2023-05-28 22:28:41 -07:00
Shadowfacts f0b8f92791 Use cached logged-in account for things
Fixes various race conditions with loading account

Closes #251
2023-05-28 22:26:46 -07:00
Shadowfacts da88303a22 Cache active account ID in CoreData
See #251
2023-05-28 22:23:04 -07:00
Shadowfacts cb5b70a23a Remove direct accesses of MastodonController.instance
Fixes potential race conditions
2023-05-28 22:10:51 -07:00
Shadowfacts 2b5b749dc8 Avoid setting duplicate breadcrumbs 2023-05-28 22:10:51 -07:00
Shadowfacts ef00c0e2df Cache own instance in CoreData
See #251
2023-05-28 22:10:10 -07:00
Shadowfacts 06f7e306e0 Provide UserAccountInfo to MastodonController at initialization 2023-05-28 21:28:20 -07:00
Shadowfacts 878744b636 Tweak how Sentry installation ID is read 2023-05-28 21:04:29 -07:00
Shadowfacts f84694b809 Fix compose toolbar background not extending to full width on landscape iPhones 2023-05-28 15:34:56 -07:00
Shadowfacts 473ef018c9 Fix DuckableContainerVC not resetting when dismissed programatically
Fixes #396
2023-05-28 15:06:59 -07:00
Shadowfacts 9a734565b0 Fix backgrounding app on iPad dismissing modally-presented VC
Closes #399
Closes #316
2023-05-28 14:37:41 -07:00
Shadowfacts 2eda9657ac Don't use deprecated interfaceOrientation for detecting portrait mode 2023-05-28 14:18:13 -07:00
Shadowfacts 203c1852d4 Reuse poll option views when updating status cell
Fixes flicker/animation due to new option views begin added in default
state and then changed back to the state of the existing view.

Fixes #403
2023-05-28 12:19:45 -07:00
Shadowfacts 708112c486 Don't reconfigure conversation main status unnecessarily 2023-05-28 12:16:48 -07:00
Shadowfacts 5b321fcc78 Remove deferred loading indicator, causes more trouble than it's worth
Closes #404
2023-05-28 11:17:16 -07:00
Shadowfacts 59231e513f Fix crash if status for leaked collection view cell updates 2023-05-27 15:38:13 -07:00
Shadowfacts bf6dfab121 Fix not checking if section exists before getting item identifiers
Closes #398
2023-05-27 15:33:33 -07:00
Shadowfacts f5f1be9f7d Fix crash due to force-unwrapping uninitialized search controller
Closes #395
2023-05-27 15:31:02 -07:00
Shadowfacts c0148bb770 Fix Delete attachment context menu action not working
Closes #394
2023-05-27 15:28:39 -07:00
Shadowfacts d938c555b7 Fix Recognize Text action accessing view context MO off of main thread
Closes #393
2023-05-27 15:26:13 -07:00
Shadowfacts 52efc8b752 Fix crash if contextMenuConfiguration called on status cell that doesn't have a delegate
Closes #392
2023-05-27 15:23:49 -07:00
Shadowfacts 822e3f91c4 Fix crash if language code is less than 3 chars
Closes #391
2023-05-27 15:23:11 -07:00
Shadowfacts d0a1aec1c0 Fix crash when action notification cell doesn't have any statuses
Closes #390
2023-05-27 15:21:34 -07:00
Shadowfacts e8305184af Fix tip jar button width changing while purchasing
Closes #389
2023-05-27 15:20:42 -07:00
Shadowfacts e9727ac2c5 Fix reblogs count button not being leading-aligned
Closes #388
2023-05-27 15:18:03 -07:00
Shadowfacts d9a6bb0fd2 Fix ambiguous constraints in poll view 2023-05-27 15:11:53 -07:00
Shadowfacts 13a807ba4f Fix poll options view blocking context menu gesture
Closes #387
2023-05-27 15:00:10 -07:00
Shadowfacts 32c5eee0b5 Fix conversation main status cell flashing wrong background color
Closes #386
2023-05-27 14:52:59 -07:00
62 changed files with 803 additions and 410 deletions

View File

@ -1,3 +1,18 @@
## 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.

View File

@ -1,5 +1,37 @@
# 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

View File

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

View File

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

View File

@ -18,19 +18,7 @@ class AutocompleteEmojisController: ViewController {
@Published var expanded = false
@Published var emojis: [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
}
@Published var emojisBySection: [String: [Emoji]] = [:]
init(composeController: ComposeController) {
self.composeController = composeController
@ -77,11 +65,20 @@ 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() {
@ -160,7 +157,7 @@ class AutocompleteEmojisController: ViewController {
private var horizontalScrollView: some View {
ScrollView(.horizontal) {
HStack(spacing: 8) {
LazyHStack(spacing: 8) {
ForEach(controller.emojis, id: \.shortcode) { emoji in
Button(action: { controller.autocomplete(with: emoji) }) {
HStack(spacing: 4) {
@ -174,8 +171,6 @@ class AutocompleteEmojisController: ViewController {
.frame(height: emojiSize)
}
.animation(.linear(duration: 0.2), value: controller.emojis)
Spacer(minLength: emojiSize)
}
.padding(.horizontal, 8)
.frame(height: emojiSize + 16)

View File

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

View File

@ -136,6 +136,7 @@ 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
@ -147,14 +148,26 @@ 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, mp4Type, quickTimeType]
[/*typeIdentifier, */ gifType, jpegType, pngType, imageType, 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: UTType(typeIdentifier)!)
attachment.fileType = typeIdentifier
attachment.fileURL = try writeDataToFile(data, id: attachment.id, type: type)
attachment.fileType = type.identifier
attachment.attachmentDescription = ""
return attachment
}

View File

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

View File

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

View File

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

View File

@ -72,7 +72,7 @@ public class InstanceFeatures: ObservableObject {
public var probablySupportsMarkdown: Bool {
switch instanceType {
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .calckey(_):
case .pleroma(_), .mastodon(.glitch, _), .mastodon(.hometown(_), _), .firefish(_):
return true
default:
return false
@ -96,7 +96,13 @@ public class InstanceFeatures: ObservableObject {
}
public var canFollowHashtags: Bool {
hasMastodonVersion(4, 0, 0)
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
}
}
public var filtersV2: Bool {
@ -128,6 +134,16 @@ 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))
}
@ -135,7 +151,7 @@ public class InstanceFeatures: ObservableObject {
public init() {
}
public func update(instance: Instance, nodeInfo: NodeInfo?) {
public func update(instance: InstanceInfo, 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") {
@ -179,8 +195,8 @@ public class InstanceFeatures: ObservableObject {
instanceType = .pixelfed
} else if nodeInfo?.software.name == "gotosocial" {
instanceType = .gotosocial
} else if ver.contains("calckey") {
instanceType = .calckey(nodeInfo?.software.version)
} else if ver.contains("firefish") || ver.contains("calckey") {
instanceType = .firefish(nodeInfo?.software.version)
} else {
instanceType = .mastodon(.vanilla, Version(string: ver))
}
@ -219,7 +235,7 @@ extension InstanceFeatures {
case pleroma(PleromaType)
case pixelfed
case gotosocial
case calckey(String?)
case firefish(String?)
var isMastodon: Bool {
if case .mastodon(_, _) = self {

View File

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

View File

@ -113,6 +113,9 @@ 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")
}
@ -397,19 +400,22 @@ public class Client {
pollOptions: [String]? = nil,
pollExpiresIn: Int? = nil,
pollMultiple: Bool? = nil,
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))
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
}
public static func editStatus(

View File

@ -107,7 +107,7 @@ extension Instance {
}
extension Instance {
public struct Configuration: Decodable, Sendable {
public struct Configuration: Codable, 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: Decodable, Sendable {
public struct StatusesConfiguration: Codable, Sendable {
public let maxCharacters: Int
public let maxMediaAttachments: Int
public let charactersReservedPerURL: Int
@ -136,7 +136,7 @@ extension Instance {
}
extension Instance {
public struct MediaAttachmentsConfiguration: Decodable, Sendable {
public struct MediaAttachmentsConfiguration: Codable, Sendable {
public let supportedMIMETypes: [String]
public let imageSizeLimit: Int
public let imageMatrixLimit: Int
@ -156,7 +156,7 @@ extension Instance {
}
extension Instance {
public struct PollsConfiguration: Decodable, Sendable {
public struct PollsConfiguration: Codable, Sendable {
public let maxOptions: Int
public let maxCharactersPerOption: Int
public let minExpiration: TimeInterval

View File

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

View File

@ -13,6 +13,7 @@ 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] = []) {

View File

@ -40,7 +40,7 @@ class ShareMastodonContext: ComposeMastodonContext, ObservableObject {
}
})
guard let instance = await instance else { return }
self.instanceFeatures.update(instance: instance, nodeInfo: await nodeInfo)
self.instanceFeatures.update(instance: InstanceInfo(instance: instance), nodeInfo: await nodeInfo)
}
Task { @MainActor in

View File

@ -79,7 +79,14 @@ class ShareViewController: UIViewController {
var attachments: [DraftAttachment] = []
for itemProvider in inputItem.attachments ?? [] {
if let attached: NSURL = await getObject(from: itemProvider) {
// 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 url == nil {
url = attached as URL
}
@ -87,8 +94,6 @@ class ShareViewController: UIViewController {
if text.isEmpty {
text = s as String
}
} else if let attachment: DraftAttachment = await getObject(from: itemProvider) {
attachments.append(attachment)
}
}

View File

@ -29,6 +29,7 @@
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 */; };
@ -426,6 +427,7 @@
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>"; };
@ -954,6 +956,7 @@
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */,
D68A76E229524D2A001DA1B3 /* ListMO.swift */,
D68A76D929511CA6001DA1B3 /* AccountPreferences.swift */,
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
);
@ -1959,6 +1962,7 @@
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 */,
@ -2386,7 +2390,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2394,7 +2398,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2452,7 +2456,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2461,7 +2465,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2478,7 +2482,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2489,7 +2493,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2507,7 +2511,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2518,7 +2522,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2536,7 +2540,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
@ -2547,7 +2551,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2691,7 +2695,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2699,7 +2703,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
OTHER_CODE_SIGN_FLAGS = "";
OTHER_LDFLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
@ -2722,7 +2726,7 @@
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@ -2730,7 +2734,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
OTHER_CODE_SIGN_FLAGS = "";
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker";
PRODUCT_NAME = "$(TARGET_NAME)";
@ -2828,7 +2832,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2837,7 +2841,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@ -2854,7 +2858,7 @@
CODE_SIGN_ENTITLEMENTS = OpenInTusker/OpenInTusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 98;
CURRENT_PROJECT_VERSION = 100;
INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
@ -2863,7 +2867,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2023.5;
MARKETING_VERSION = 2023.6;
PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_ID_PREFIX).Tusker.OpenInTusker";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@ -23,15 +23,12 @@ 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)
controller.accountInfo = account
controller.client.clientID = account.clientID
controller.client.clientSecret = account.clientSecret
controller.client.accessToken = account.accessToken
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
all[account] = controller
return controller
}
@ -46,7 +43,7 @@ class MastodonController: ObservableObject {
}
private let transient: Bool
private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
var accountInfo: UserAccountInfo?
@ -55,8 +52,9 @@ class MastodonController: ObservableObject {
let client: Client!
let instanceFeatures = InstanceFeatures()
@Published private(set) var account: Account!
@Published private(set) var instance: Instance!
@Published private(set) var account: AccountMO?
@Published private(set) var instance: Instance?
@Published private(set) var instanceInfo: InstanceInfo!
@Published private(set) var nodeInfo: NodeInfo!
@Published private(set) var lists: [List] = []
@Published private(set) var customEmojis: [Emoji]?
@ -72,27 +70,49 @@ class MastodonController: ObservableObject {
accountInfo != nil
}
init(instanceURL: URL, transient: Bool = false) {
// main-actor b/c fetchActiveAccountID and fetchActiveInstance use the viewContext
@MainActor
init(instanceURL: URL, accountInfo: UserAccountInfo?) {
self.instanceURL = instanceURL
self.accountInfo = nil
self.accountInfo = accountInfo
self.client = Client(baseURL: instanceURL, session: .appDefault)
self.transient = transient
self.transient = accountInfo == nil
self.persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
$instance
self.client.clientID = accountInfo?.clientID
self.client.clientSecret = accountInfo?.clientSecret
self.client.accessToken = accountInfo?.accessToken
if !transient {
fetchActiveAccount()
fetchActiveInstance()
}
$instanceInfo
.compactMap { $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)
}
.store(in: &cancellables)
$instanceInfo
.compactMap { $0 }
.removeDuplicates(by: { $0.version == $1.version })
.combineLatest($nodeInfo.removeDuplicates())
.sink { (instance, nodeInfo) in
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
$instance
.compactMap { $0 }
.sink { [unowned self] in
self.updateActiveInstance(from: $0)
self.instanceInfo = InstanceInfo(instance: $0)
}
.store(in: &cancellables)
instanceFeatures.featuresUpdated
.filter { [unowned self] _ in self.instanceFeatures.canFollowHashtags && self.followedHashtags.isEmpty }
.sink { [unowned self] _ in
@ -103,6 +123,12 @@ 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)
@ -207,8 +233,8 @@ class MastodonController: ObservableObject {
}
}
func getOwnAccount(completion: ((Result<Account, Client.Error>) -> Void)? = nil) {
if account != nil {
func getOwnAccount(completion: ((Result<AccountMO, Client.Error>) -> Void)? = nil) {
if let account {
completion?(.success(account))
} else {
let request = Client.getSelfAccount()
@ -218,25 +244,25 @@ class MastodonController: ObservableObject {
completion?(.failure(error))
case let .success(account, _):
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)
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)
} else {
// the first time the user's account is added to the store,
// increment its reference count so that it's never removed
self.persistentContainer.addOrUpdate(account: account)
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
}
completion?(.success(account))
accountMO.active = true
self.account = accountMO
completion?(.success(accountMO))
}
}
}
}
}
func getOwnAccount() async throws -> Account {
func getOwnAccount() async throws -> AccountMO {
if let account = account {
return account
} else {
@ -332,6 +358,37 @@ 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)
@ -522,7 +579,7 @@ class MastodonController: ObservableObject {
}
private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) {
private func setInstanceBreadcrumb(instance: InstanceInfo, nodeInfo: NodeInfo?) {
let crumb = Breadcrumb(level: .info, category: "MastodonController")
crumb.data = [
"instance": [

View File

@ -17,7 +17,6 @@ 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) {
@ -31,26 +30,30 @@ class ReblogService {
requireConfirmation {
presentConfirmationAlert()
} else {
await doToggleReblog()
await doToggleReblog(visibility: nil)
}
}
private func presentConfirmationAlert() {
let image: UIImage?
let reblogVisibilityActions: [CustomAlertController.MenuAction]?
let reblogVisibilityActions: [CustomAlertController.MenuAction]
let maximumVisibility = status.visibility
if mastodonController.instanceFeatures.reblogVisibility {
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)) {
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)) {
// deliberately retain a strong reference to self
Task {
await self.doToggleReblog()
await self.doToggleReblog(visibility: visibility)
}
}
}
} else {
image = nil
reblogVisibilityActions = nil
reblogVisibilityActions = []
}
let preview = ConfirmReblogStatusPreviewView(status: status)
@ -59,11 +62,11 @@ class ReblogService {
CustomAlertController.Action(title: "Reblog", image: image, style: .default, handler: {
// deliberately retain a strong reference to self
Task {
await self.doToggleReblog()
await self.doToggleReblog(visibility: nil)
}
})
])
if let reblogVisibilityActions {
if !reblogVisibilityActions.isEmpty {
var menuAction = CustomAlertController.Action(title: nil, image: UIImage(systemName: "chevron.down"), style: .menu(reblogVisibilityActions), handler: nil)
menuAction.isSecondaryMenu = true
config.actions.append(menuAction)
@ -72,7 +75,7 @@ class ReblogService {
presenter.present(alert, animated: true)
}
private func doToggleReblog() async {
private func doToggleReblog(visibility: Visibility?) async {
let oldValue = status.reblogged
status.reblogged.toggle()
mastodonController.persistentContainer.statusSubject.send(status.id)

View File

@ -22,7 +22,9 @@ class ToggleFollowHashtagService {
self.presenter = presenter
}
func toggleFollow() async {
@discardableResult
func toggleFollow() async -> Bool {
let success: Bool
let context = mastodonController.persistentContainer.viewContext
var config: ToastConfiguration
if let existing = mastodonController.followedHashtags.first(where: { $0.name == hashtagName }) {
@ -36,11 +38,14 @@ 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 {
@ -53,15 +58,19 @@ 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
}
}

View File

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

View File

@ -25,6 +25,8 @@ 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

View File

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

View File

@ -311,6 +311,14 @@ 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()

View File

@ -2,6 +2,7 @@
<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"/>
@ -33,6 +34,12 @@
<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"/>
@ -138,7 +145,6 @@
<memberEntity name="TimelinePosition"/>
</configuration>
<configuration name="Local">
<memberEntity name="Account"/>
<memberEntity name="Filter"/>
<memberEntity name="FilterKeyword"/>
<memberEntity name="FollowedHashtag"/>
@ -146,5 +152,7 @@
<memberEntity name="Status"/>
<memberEntity name="TimelineState"/>
<memberEntity name="List"/>
<memberEntity name="Account"/>
<memberEntity name="ActiveInstance"/>
</configuration>
</model>

View File

@ -23,11 +23,12 @@ 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)?
@ -45,7 +46,6 @@ 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,6 +59,12 @@ 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) {
@ -107,7 +113,6 @@ class ComposeHostingController: UIHostingController<ComposeHostingController.Vie
return
} else {
dismiss(animated: true)
duckableDelegate?.duckableViewControllerWillDismiss(animated: true)
}
}

View File

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

View File

@ -57,10 +57,6 @@ 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)
@ -79,6 +75,9 @@ 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)
@ -194,7 +193,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
snapshot.appendItems([.trends], toSection: .discover)
}
private func ownInstanceLoaded(_ instance: Instance) {
private func instanceFeaturesChanged() {
var snapshot = self.dataSource.snapshot()
if mastodonController.instanceFeatures.trends,
!snapshot.sectionIdentifiers.contains(.discover) {
@ -289,15 +288,6 @@ 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!)
@ -308,36 +298,45 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
}
private func trailingSwipeActionsForCell(at indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let title: String
let handler: UIContextualAction.Handler
var actions = [UIContextualAction]()
switch dataSource.itemIdentifier(for: indexPath) {
case let .list(list):
title = NSLocalizedString("Delete", comment: "delete swipe action title")
handler = { (_, _, completion) in
actions.append(UIContextualAction(style: .destructive, title: "Delete", handler: { _, _, completion in
self.deleteList(list, completion: completion)
}
}))
case let .savedHashtag(hashtag):
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in
self.removeSavedHashtag(hashtag)
completion(true)
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)
}
}))
}
case let .savedInstance(url):
title = NSLocalizedString("Unsave", comment: "unsave swipe action title")
handler = { (_, _, completion) in
actions.append(UIContextualAction(style: .destructive, title: "Unsave", handler: { _, _, completion in
self.removeSavedInstance(url)
completion(true)
}
}))
default:
return nil
}
return UISwipeActionsConfiguration(actions: [
UIContextualAction(style: .destructive, title: title, handler: handler)
])
return UISwipeActionsConfiguration(actions: actions)
}
// MARK: - Collection View Delegate
@ -582,3 +581,7 @@ extension ExploreViewController: UICollectionViewDragDelegate {
return [UIDragItem(itemProvider: provider)]
}
}
extension ExploreViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}

View File

@ -97,10 +97,6 @@ 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)
@ -189,14 +185,6 @@ 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,

View File

@ -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)
tabBarViewController.select(tab: tab, dismissPresented: false)
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 {
if search.searchController?.isActive == true {
// 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)
tabBarViewController.select(tab: .explore, dismissPresented: false)
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
tabBarViewController.select(tab: .explore)
tabBarViewController.select(tab: .explore, dismissPresented: false)
// 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

View File

@ -111,13 +111,13 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
repositionFastSwitcherIndicator()
}
func select(tab: Tab) {
func select(tab: Tab, dismissPresented: Bool) {
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 {
if presentedViewController != nil && dismissPresented {
dismiss(animated: true) {
self.selectedIndex = tab.rawValue
}
@ -141,8 +141,8 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
return
}
NSLayoutConstraint.deactivate(fastSwitcherConstraints)
// using interfaceOrientation isn't ideal, but UITabBar buttons may lay out horizontally even in the compact size class
if traitCollection.horizontalSizeClass == .compact && interfaceOrientation.isPortrait {
let isPortrait = view.bounds.width < view.bounds.height
if traitCollection.horizontalSizeClass == .compact && 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)
select(tab: .timelines, dismissPresented: true)
case .notifications:
select(tab: .notifications)
select(tab: .notifications, dismissPresented: true)
case .myProfile:
select(tab: .myProfile)
select(tab: .myProfile, dismissPresented: true)
case .explore:
select(tab: .explore)
select(tab: .explore, dismissPresented: true)
case .bookmarks:
select(tab: .explore)
select(tab: .explore, dismissPresented: true)
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated)
case .list(id: let id):
select(tab: .explore)
select(tab: .explore, dismissPresented: true)
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)
select(tab: .explore, dismissPresented: true)
exploreNavController.popToRootViewController(animated: false)
// setting searchController.isActive directly doesn't work until the view has loaded/appeared for the first time

View File

@ -48,11 +48,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
$0.axis = .horizontal
$0.alignment = .fill
$0.spacing = 8
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
$0.heightAnchor.constraint(equalToConstant: 30).isActive = true
}
private lazy var actionLabel = MultiSourceEmojiLabel().configure {
@ -146,7 +142,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 = .scaleAspectFit
imageView.contentMode = .scaleAspectFill
imageView.layer.masksToBounds = true
imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30
imageView.layer.cornerCurve = .continuous
@ -240,7 +236,9 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
override var accessibilityLabel: String? {
get {
let first = group.notifications.first!
guard let first = group.notifications.first else {
return nil
}
var str = ""
switch group.kind {
case .favourite:

View File

@ -45,9 +45,7 @@ class FollowNotificationGroupCollectionViewCell: UICollectionViewListCell {
]).configure {
$0.axis = .horizontal
$0.alignment = .fill
let heightConstraint = $0.heightAnchor.constraint(equalToConstant: 30)
heightConstraint.priority = .init(999)
heightConstraint.isActive = true
$0.heightAnchor.constraint(equalToConstant: 30).isActive = true
}
private lazy var actionLabel = MultiSourceEmojiLabel().configure {
@ -121,6 +119,7 @@ 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

View File

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

View File

@ -263,6 +263,9 @@ 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 }
@ -395,13 +398,19 @@ 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) {
if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite || notif.kind == .status) {
let crumb = Breadcrumb(level: .fatal, category: "notifications")
crumb.data = [
"id": notif.id,

View File

@ -91,9 +91,6 @@ 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),
@ -101,7 +98,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),
vStackBottomConstraint,
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)

View File

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

View File

@ -77,7 +77,11 @@ struct TipJarView: View {
}
}
.onPreferenceChange(ButtonWidthKey.self) { newValue in
self.buttonWidth = newValue
if let buttonWidth {
self.buttonWidth = max(buttonWidth, newValue)
} else {
self.buttonWidth = newValue
}
}
if let total = getTotalTips(), total > 0 {

View File

@ -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 })

View File

@ -24,9 +24,6 @@ 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 {
@ -173,6 +170,11 @@ struct ReportView: View {
}
}
}
.onReceive(mastodonController.$instance) { instance in
if instance?.rules == nil {
report.reason = .spam
}
}
}
private func sendReport() {

View File

@ -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(multiple: false)
let icon = PollOptionCheckboxView()
icon.readOnly = false // this is a lie, but it's only used for stylistic changes
let label = EmojiLabel()
label.text = option.title

View File

@ -518,7 +518,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if let centerStatusID,
let index = statusIDs.firstIndex(of: centerStatusID) {
self.scrollToItem(item: items[index])
stateRestorationLogger.fault("TimelineViewController: restored statuses with center ID \(centerStatusID)")
stateRestorationLogger.info("TimelineViewController: restored statuses with center ID \(centerStatusID)")
} else {
stateRestorationLogger.fault("TimelineViewController: restored statuses, but couldn't find center ID")
}

View File

@ -486,6 +486,7 @@ 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

View File

@ -63,19 +63,20 @@ class TimelineLikeController<Item: Sendable> {
}
let token = LoadAttemptToken()
state = .loadingInitial(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
await emit(event: .addLoadingIndicator)
state = .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 loadingIndicator.end()
await emit(event: .removeLoadingIndicator)
await emit(event: .loadAllError(error, token))
state = .notLoadedInitial
}
@ -88,9 +89,10 @@ class TimelineLikeController<Item: Sendable> {
}
let token = LoadAttemptToken()
state = .restoringInitial(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .restoringInitial(token, hasAddedLoadingIndicator: true))
await emit(event: .addLoadingIndicator)
state = .restoringInitial(token, hasAddedLoadingIndicator: true)
await doRestore()
await loadingIndicator.end()
await emit(event: .removeLoadingIndicator)
state = .idle
}
@ -128,19 +130,20 @@ class TimelineLikeController<Item: Sendable> {
return
}
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
await emit(event: .addLoadingIndicator)
state = .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 loadingIndicator.end()
await emit(event: .removeLoadingIndicator)
await emit(event: .loadOlderError(error, token))
state = .idle
}
@ -349,34 +352,6 @@ 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 {

View File

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

View File

@ -46,6 +46,13 @@ 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()

View File

@ -75,6 +75,10 @@ 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)

View File

@ -10,6 +10,8 @@ import UIKit
class PollOptionCheckboxView: UIView {
private static let size: CGFloat = 20
var isChecked: Bool = false {
didSet {
updateStyle()
@ -25,16 +27,19 @@ class PollOptionCheckboxView: UIView {
updateStyle()
}
}
var multiple: Bool = false {
didSet {
updateStyle()
}
}
private let imageView: UIImageView
init(multiple: Bool) {
init() {
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
@ -46,7 +51,7 @@ class PollOptionCheckboxView: UIView {
NSLayoutConstraint.activate([
widthAnchor.constraint(equalTo: heightAnchor),
widthAnchor.constraint(equalToConstant: size),
widthAnchor.constraint(equalToConstant: PollOptionCheckboxView.size),
imageView.widthAnchor.constraint(equalTo: widthAnchor, constant: -3),
imageView.heightAnchor.constraint(equalTo: heightAnchor, constant: -3),
@ -64,6 +69,8 @@ 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

View File

@ -11,35 +11,43 @@ 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!
private(set) var checkbox: PollOptionCheckboxView?
@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?
init(poll: Poll, option: Poll.Option, mastodonController: MastodonController) {
init() {
super.init(frame: .zero)
let minHeight: CGFloat = 35
layer.cornerRadius = 0.1 * minHeight
layer.cornerRadius = PollOptionView.cornerRadius
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)
let percentLabel = UILabel()
percentLabel = UILabel()
percentLabel.translatesAutoresizingMaskIntoConstraints = false
percentLabel.text = "100%"
percentLabel.font = label.font
@ -48,6 +56,48 @@ 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,
@ -65,56 +115,23 @@ class PollOptionView: UIView {
percentLabel.isHidden = false
percentLabel.text = percent
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),
])
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
accessibilityLabel! += ", \(percent)"
}
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
percentLabel.isHidden = true
_fillView.valueIfInitialized?.removeFromSuperview()
}
isAccessibilityElement = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

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

View File

@ -32,10 +32,12 @@ 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

View File

@ -67,6 +67,7 @@ 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([
@ -77,7 +78,7 @@ class StatusPollView: UIView, StatusContentPollView {
infoLabel.leadingAnchor.constraint(equalTo: leadingAnchor),
infoLabel.topAnchor.constraint(equalTo: optionsView.bottomAnchor),
infoLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
infoLabel.trailingAnchor.constraint(lessThanOrEqualTo: voteButton.leadingAnchor, constant: -8),
infoLabel.trailingAnchor.constraint(equalTo: voteButton.leadingAnchor, constant: -8),
voteButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 44),
voteButton.trailingAnchor.constraint(equalTo: trailingAnchor),
@ -141,7 +142,7 @@ class StatusPollView: UIView, StatusContentPollView {
}
func estimateHeight(effectiveWidth: CGFloat) -> CGFloat {
guard let poll else { return 0 }
guard poll != nil else { return 0 }
return optionsView.estimateHeight(effectiveWidth: effectiveWidth) + infoLabel.sizeThatFits(UIView.layoutFittingExpandedSize).height
}

View File

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

View File

@ -75,6 +75,13 @@ 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() }

View File

@ -28,6 +28,7 @@ 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([
@ -140,12 +141,23 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
$0.isPointerInteractionEnabled = true
}
private lazy var actionsCountHStack = UIStackView(arrangedSubviews: [
reblogsCountButton,
favoritesCountButton,
]).configure {
$0.axis = .horizontal
$0.spacing = 8
// 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 let timestampAndClientLabel = UILabel().configure {
@ -326,7 +338,12 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
}
override func updateConfiguration(using state: UICellConfigurationState) {
backgroundConfiguration = .appListPlainCell(for: state)
var config = UIBackgroundConfiguration.listPlainCell().updated(for: state)
// conv main status isn't selectable
if !state.isFocused {
config.backgroundColor = .appBackground
}
backgroundConfiguration = config
}
// MARK: Configure UI

View File

@ -30,7 +30,7 @@ class StatusCardView: UIView {
private var titleLabel: UILabel!
private var descriptionLabel: UILabel!
private var domainLabel: UILabel!
private var imageView: CachedImageView!
private var imageView: StatusCardImageView!
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 = CachedImageView(cache: .attachments)
imageView = StatusCardImageView(cache: .attachments)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
@ -167,7 +167,18 @@ class StatusCardView: UIView {
}
if let image = card.image {
imageView.update(for: URL(image), blurhash: card.blurhash)
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.isHidden = false
leadingSpacer.isHidden = true
} else {
@ -257,3 +268,25 @@ extension StatusCardView: UIContextMenuInteractionDelegate {
}
}
}
private class StatusCardImageView: CachedImageView {
@Lazy private var blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
var blurImage = false {
didSet {
if blurImage {
if !_blurView.isInitialized {
blurView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurView)
NSLayoutConstraint.activate([
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
} else {
_blurView.valueIfInitialized?.removeFromSuperview()
}
}
}
}

View File

@ -60,7 +60,8 @@ extension StatusCollectionViewCell {
.receive(on: DispatchQueue.main)
.filter { [unowned self] in $0 == self.statusID }
.sink { [unowned self] _ in
if let status = self.mastodonController.persistentContainer.status(for: self.statusID) {
if let mastodonController = self.mastodonController,
let status = mastodonController.persistentContainer.status(for: self.statusID) {
// update immediately w/o animation
self.favoriteButton.active = status.favourited
self.reblogButton.active = status.reblogged
@ -124,7 +125,19 @@ extension StatusCollectionViewCell {
statusState.collapsed = false
}
}
collapseButton.isHidden = !statusState.collapsible!
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
}
}
contentContainer.setCollapsed(statusState.collapsed!)
if statusState.collapsed! {
contentContainer.alpha = 0

View File

@ -50,8 +50,9 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
private var isHiddenObservations: [NSKeyValueObservation] = []
private var visibleSubviews = IndexSet()
private var verticalConstraints: [NSLayoutConstraint] = []
private var lastSubviewBottomConstraint: NSLayoutConstraint?
private var lastSubviewBottomConstraint: (UIView, NSLayoutConstraint)?
private var zeroHeightConstraint: NSLayoutConstraint!
private var isCollapsed = false
@ -93,31 +94,35 @@ class StatusContentContainer<ContentView: ContentTextView, PollView: StatusConte
}
override func updateConstraints() {
NSLayoutConstraint.deactivate(verticalConstraints)
verticalConstraints = []
var lastVisibleSubview: UIView?
let visibleSubviews = IndexSet(arrangedSubviews.indices.filter { !arrangedSubviews[$0].isHidden })
if self.visibleSubviews != visibleSubviews {
self.visibleSubviews = visibleSubviews
NSLayoutConstraint.deactivate(verticalConstraints)
verticalConstraints = []
var lastVisibleSubview: UIView?
for subview in arrangedSubviews {
guard !subview.isHidden else {
continue
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
}
if let lastVisibleSubview {
verticalConstraints.append(subview.topAnchor.constraint(equalTo: lastVisibleSubview.bottomAnchor, constant: 4))
} else {
verticalConstraints.append(subview.topAnchor.constraint(equalTo: topAnchor))
}
lastVisibleSubview = subview
NSLayoutConstraint.activate(verticalConstraints)
}
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
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)
}
zeroHeightConstraint.isActive = isCollapsed
@ -133,7 +138,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.isActive = !collapsed
lastSubviewBottomConstraint.1.isActive = !collapsed
zeroHeightConstraint.isActive = collapsed
}
}

View File

@ -35,9 +35,11 @@ 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
$0.heightAnchor.constraint(lessThanOrEqualToConstant: TimelineStatusCollectionViewCell.timelineReasonIconSize),
heightConstraint,
$0.widthAnchor.constraint(equalTo: $0.heightAnchor),
])
}
@ -60,21 +62,26 @@ 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),
avatarImageView.topAnchor.constraint(equalTo: $0.topAnchor),
avatarTopConstraint,
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),
metaIndicatorsView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor, constant: 4),
metaIndicatorsTopConstraint,
])
}
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([
@ -176,6 +183,7 @@ 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 {
@ -315,17 +323,12 @@ 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)
// 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)
mainContainerTopToSelfConstraint.identifier = "MainContainerTopToSelf"
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),
@ -334,13 +337,14 @@ 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),
metaIndicatorsBottomConstraint,
metaIndicatorsView.bottomAnchor.constraint(lessThanOrEqualTo: statusContainer.bottomAnchor, constant: -6),
])
updateActionsVisibility()
@ -535,6 +539,21 @@ 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)
@ -545,6 +564,10 @@ 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")
@ -552,20 +575,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
createObservers()
self.statusState = state
var hideTimelineReason = true
if let rebloggedStatus = status.reblog {
reblogStatusID = statusID
rebloggerID = status.account.id
if let reblogStatus {
hideTimelineReason = false
updateRebloggerLabel(reblogger: status.account)
status = rebloggedStatus
} else {
reblogStatusID = nil
rebloggerID = nil
updateRebloggerLabel(reblogger: reblogStatus.account)
}
if showFollowedHashtags {
@ -579,8 +593,14 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
timelineReasonHStack.isHidden = hideTimelineReason
mainContainerTopToReblogLabelConstraint.isActive = !hideTimelineReason
mainContainerTopToSelfConstraint.isActive = 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
}
doUpdateUI(status: status, precomputedContent: precomputedContent)
@ -679,11 +699,11 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
private func updateActionsVisibility() {
if Preferences.shared.hideActionsInTimeline {
if Preferences.shared.hideActionsInTimeline && !actionsContainer.isHidden {
actionsContainer.isHidden = true
mainContainerBottomToSelfConstraint.isActive = true
mainContainerBottomToActionsConstraint.isActive = false
} else {
mainContainerBottomToSelfConstraint.isActive = true
} else if !Preferences.shared.hideActionsInTimeline && actionsContainer.isHidden {
actionsContainer.isHidden = false
mainContainerBottomToSelfConstraint.isActive = false
mainContainerBottomToActionsConstraint.isActive = true
@ -784,7 +804,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
func contextMenuConfiguration() -> UIContextMenuConfiguration? {
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
guard let mastodonController,
let status = mastodonController.persistentContainer.status(for: statusID) else {
return nil
}
return UIContextMenuConfiguration {

View File

@ -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 == "401" || code == "403" || code == "404" || code == "502" || code == "503" {
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 .calckey(let calckeyVersion):
event.tags!["instance_type"] = "calckey"
case .firefish(let calckeyVersion):
event.tags!["instance_type"] = "firefish"
if let calckeyVersion {
event.tags!["calckey_version"] = calckeyVersion
}