Merge branch 'develop' into compose-redesign-15
This commit is contained in:
commit
a7924feb76
@ -1,3 +1,13 @@
|
|||||||
|
## 2024.5
|
||||||
|
Features/Improvements:
|
||||||
|
- Improve gallery animations
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Handle right-to-left text in display names
|
||||||
|
- Fix crash during gifv playback
|
||||||
|
- iPadOS: Fix app becoming unresponsive when switching accounts
|
||||||
|
- iPadOS/macOS: Fix Cmd+R shortcuts not working
|
||||||
|
|
||||||
## 2024.4
|
## 2024.4
|
||||||
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
|
This release introduces support for iOS 18, including a new sidebar/tab bar on iPad, as well as bugfixes and improvements.
|
||||||
|
|
||||||
|
17
CHANGELOG.md
17
CHANGELOG.md
@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2024.5 (141)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix gallery controls being positioned incorrectly during dismiss animation on certain devices
|
||||||
|
- Fix gallery controls being positioned incorrectly in landscape orientations
|
||||||
|
|
||||||
|
## 2024.5 (139)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix error decoding certain posts
|
||||||
|
|
||||||
|
## 2024.5 (138)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix potential crash when displaying certain attachments
|
||||||
|
- Fix potential crash due to race condition when opening push notification in app
|
||||||
|
- Fix misaligned text between profile field values/labels
|
||||||
|
- Fix rate limited error message not including reset timestamp
|
||||||
|
- iPadOS/macOS: Fix Cmd+R shortcut not working
|
||||||
|
|
||||||
## 2024.5 (137)
|
## 2024.5 (137)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Improve gallery presentation/dismissal transitions
|
- Improve gallery presentation/dismissal transitions
|
||||||
|
@ -75,11 +75,11 @@ public final class ComposeController: ViewController {
|
|||||||
|
|
||||||
var postButtonEnabled: Bool {
|
var postButtonEnabled: Bool {
|
||||||
draft.editedStatusID != nil ||
|
draft.editedStatusID != nil ||
|
||||||
(draft.hasContent
|
(draft.hasContent
|
||||||
&& charactersRemaining >= 0
|
&& charactersRemaining >= 0
|
||||||
&& !isPosting
|
&& !isPosting
|
||||||
&& attachmentsListController.isValid
|
&& attachmentsListController.isValid
|
||||||
&& isPollValid)
|
&& isPollValid)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var isPollValid: Bool {
|
private var isPollValid: Bool {
|
||||||
@ -419,9 +419,9 @@ public final class ComposeController: ViewController {
|
|||||||
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
|
becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
|
||||||
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
|
focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
|
||||||
)
|
)
|
||||||
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
.listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
.listRowBackground(config.backgroundColor)
|
.listRowBackground(config.backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
MainTextView()
|
MainTextView()
|
||||||
|
@ -11,6 +11,7 @@ import AVFoundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
protocol GalleryItemViewControllerDelegate: AnyObject {
|
protocol GalleryItemViewControllerDelegate: AnyObject {
|
||||||
func isGalleryBeingPresented() -> Bool
|
func isGalleryBeingPresented() -> Bool
|
||||||
|
func isGalleryBeingDismissed() -> Bool
|
||||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
|
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
|
||||||
func galleryItemClose(_ item: GalleryItemViewController)
|
func galleryItemClose(_ item: GalleryItemViewController)
|
||||||
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
|
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
|
||||||
@ -397,13 +398,27 @@ class GalleryItemViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateTopControlsInsets() {
|
private func updateTopControlsInsets() {
|
||||||
|
guard delegate?.isGalleryBeingDismissed() != true else {
|
||||||
|
return
|
||||||
|
}
|
||||||
let notchedDeviceTopInsets: [CGFloat] = [
|
let notchedDeviceTopInsets: [CGFloat] = [
|
||||||
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
|
||||||
48, // iPhone XR, 11
|
48, // iPhone XR, 11
|
||||||
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
|
||||||
50, // iPhone 12 mini, 13 mini
|
50, // iPhone 12 mini, 13 mini
|
||||||
]
|
]
|
||||||
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
|
let topInset: CGFloat
|
||||||
|
switch view.window?.windowScene?.interfaceOrientation {
|
||||||
|
case .portraitUpsideDown:
|
||||||
|
topInset = view.safeAreaInsets.bottom
|
||||||
|
case .landscapeLeft:
|
||||||
|
topInset = view.safeAreaInsets.right
|
||||||
|
case .landscapeRight:
|
||||||
|
topInset = view.safeAreaInsets.left
|
||||||
|
default:
|
||||||
|
topInset = view.safeAreaInsets.top
|
||||||
|
}
|
||||||
|
if notchedDeviceTopInsets.contains(topInset) {
|
||||||
// the notch width is not the same for the iPhones 13,
|
// the notch width is not the same for the iPhones 13,
|
||||||
// but what we actually want is the same offset from the edges
|
// but what we actually want is the same offset from the edges
|
||||||
// since the corner radius didn't change
|
// since the corner radius didn't change
|
||||||
@ -412,7 +427,7 @@ class GalleryItemViewController: UIViewController {
|
|||||||
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
|
||||||
shareButtonLeadingConstraint.constant = offset
|
shareButtonLeadingConstraint.constant = offset
|
||||||
closeButtonTrailingConstraint.constant = offset
|
closeButtonTrailingConstraint.constant = offset
|
||||||
} else if view.safeAreaInsets.top == 0 {
|
} else if topInset == 0 {
|
||||||
// square corner devices
|
// square corner devices
|
||||||
shareButtonLeadingConstraint.constant = 8
|
shareButtonLeadingConstraint.constant = 8
|
||||||
shareButtonTopConstraint.constant = 8
|
shareButtonTopConstraint.constant = 8
|
||||||
|
@ -149,6 +149,10 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
|
|||||||
isBeingPresented
|
isBeingPresented
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isGalleryBeingDismissed() -> Bool {
|
||||||
|
isBeingDismissed
|
||||||
|
}
|
||||||
|
|
||||||
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
|
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
|
||||||
presentationAnimationCompletionHandlers.append(block)
|
presentationAnimationCompletionHandlers.append(block)
|
||||||
}
|
}
|
||||||
|
@ -25,27 +25,30 @@ public struct Client: Sendable {
|
|||||||
|
|
||||||
public var timeoutInterval: TimeInterval = 60
|
public var timeoutInterval: TimeInterval = 60
|
||||||
|
|
||||||
static let decoder: JSONDecoder = {
|
private static let dateFormatter: DateFormatter = {
|
||||||
let decoder = JSONDecoder()
|
|
||||||
|
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
|
||||||
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
formatter.timeZone = TimeZone(abbreviation: "UTC")
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
let iso8601 = ISO8601DateFormatter()
|
return formatter
|
||||||
|
}()
|
||||||
|
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||||
|
private static func decodeDate(string: String) -> Date? {
|
||||||
|
// for the next time mastodon accidentally changes date formats >.>
|
||||||
|
return dateFormatter.date(from: string) ?? iso8601Formatter.date(from: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let decoder: JSONDecoder = {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
decoder.dateDecodingStrategy = .custom({ (decoder) in
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let str = try container.decode(String.self)
|
let str = try container.decode(String.self)
|
||||||
// for the next time mastodon accidentally changes date formats >.>
|
if let date = Self.decodeDate(string: str) {
|
||||||
if let date = formatter.date(from: str) {
|
|
||||||
return date
|
|
||||||
} else if let date = iso8601.date(from: str) {
|
|
||||||
return date
|
return date
|
||||||
} else {
|
} else {
|
||||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return decoder
|
return decoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -105,6 +108,15 @@ public struct Client: Sendable {
|
|||||||
return task
|
return task
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func error(from response: HTTPURLResponse) -> ErrorType {
|
||||||
|
if response.statusCode == 429,
|
||||||
|
let date = response.value(forHTTPHeaderField: "X-RateLimit-Reset").flatMap(Self.decodeDate) {
|
||||||
|
return .rateLimited(date)
|
||||||
|
} else {
|
||||||
|
return .unexpectedStatus(response.statusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
@ -575,6 +587,8 @@ extension Client {
|
|||||||
return "Invalid Model"
|
return "Invalid Model"
|
||||||
case .mastodonError(let code, let error):
|
case .mastodonError(let code, let error):
|
||||||
return "Server Error (\(code)): \(error)"
|
return "Server Error (\(code)): \(error)"
|
||||||
|
case .rateLimited(let reset):
|
||||||
|
return "Rate Limited Until \(reset.formatted(date: .omitted, time: .standard))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -585,6 +599,7 @@ extension Client {
|
|||||||
case invalidResponse
|
case invalidResponse
|
||||||
case invalidModel(Swift.Error)
|
case invalidModel(Swift.Error)
|
||||||
case mastodonError(Int, String)
|
case mastodonError(Int, String)
|
||||||
|
case rateLimited(Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NodeInfoError: LocalizedError {
|
enum NodeInfoError: LocalizedError {
|
||||||
|
@ -11,9 +11,15 @@ import Foundation
|
|||||||
public struct NodeInfo: Decodable, Sendable, Equatable {
|
public struct NodeInfo: Decodable, Sendable, Equatable {
|
||||||
public let version: String
|
public let version: String
|
||||||
public let software: Software
|
public let software: Software
|
||||||
|
public let metadata: Metadata
|
||||||
|
|
||||||
public struct Software: Decodable, Sendable, Equatable {
|
public struct Software: Decodable, Sendable, Equatable {
|
||||||
public let name: String
|
public let name: String
|
||||||
public let version: String
|
public let version: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct Metadata: Decodable, Sendable, Equatable {
|
||||||
|
public let nodeName: String
|
||||||
|
public let nodeDescription: String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -197,72 +197,72 @@ class NotificationGroupTests: XCTestCase {
|
|||||||
|
|
||||||
func testGroupSimple() {
|
func testGroupSimple() {
|
||||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
|
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeA2], only: [.favourite])
|
||||||
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2])!])
|
XCTAssertEqual(groups, [NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testGroupWithOtherGroupableInBetween() {
|
func testGroupWithOtherGroupableInBetween() {
|
||||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
|
let groups = NotificationGroup.createGroups(notifications: [likeA1, likeB, likeA2], only: [.favourite])
|
||||||
XCTAssertEqual(groups, [
|
XCTAssertEqual(groups, [
|
||||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||||
NotificationGroup(notifications: [likeB])!,
|
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDontGroupWithUngroupableInBetween() {
|
func testDontGroupWithUngroupableInBetween() {
|
||||||
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
|
let groups = NotificationGroup.createGroups(notifications: [likeA1, mentionB, likeA2], only: [.favourite])
|
||||||
XCTAssertEqual(groups, [
|
XCTAssertEqual(groups, [
|
||||||
NotificationGroup(notifications: [likeA1])!,
|
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
|
||||||
NotificationGroup(notifications: [mentionB])!,
|
NotificationGroup(notifications: [mentionB], kind: .mention)!,
|
||||||
NotificationGroup(notifications: [likeA2])!,
|
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMergeSimpleGroups() {
|
func testMergeSimpleGroups() {
|
||||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||||
let group2 = NotificationGroup(notifications: [likeA2])!
|
let group2 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||||
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
|
let merged = NotificationGroup.mergeGroups(first: [group1], second: [group2], only: [.favourite])
|
||||||
XCTAssertEqual(merged, [
|
XCTAssertEqual(merged, [
|
||||||
NotificationGroup(notifications: [likeA1, likeA2])!
|
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMergeGroupsWithOtherGroupableInBetween() {
|
func testMergeGroupsWithOtherGroupableInBetween() {
|
||||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||||
let group2 = NotificationGroup(notifications: [likeB])!
|
let group2 = NotificationGroup(notifications: [likeB], kind: .favourite)!
|
||||||
let group3 = NotificationGroup(notifications: [likeA2])!
|
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||||
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||||
XCTAssertEqual(merged, [
|
XCTAssertEqual(merged, [
|
||||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||||
NotificationGroup(notifications: [likeB])!,
|
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||||
])
|
])
|
||||||
|
|
||||||
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
|
let merged2 = NotificationGroup.mergeGroups(first: [group1], second: [group2, group3], only: [.favourite])
|
||||||
XCTAssertEqual(merged2, [
|
XCTAssertEqual(merged2, [
|
||||||
NotificationGroup(notifications: [likeA1, likeA2])!,
|
NotificationGroup(notifications: [likeA1, likeA2], kind: .favourite)!,
|
||||||
NotificationGroup(notifications: [likeB])!,
|
NotificationGroup(notifications: [likeB], kind: .favourite)!,
|
||||||
])
|
])
|
||||||
|
|
||||||
let group4 = NotificationGroup(notifications: [likeB2])!
|
let group4 = NotificationGroup(notifications: [likeB2], kind: .favourite)!
|
||||||
let group5 = NotificationGroup(notifications: [mentionB])!
|
let group5 = NotificationGroup(notifications: [mentionB], kind: .mention)!
|
||||||
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
|
let merged3 = NotificationGroup.mergeGroups(first: [group1, group5, group2], second: [group4, group3], only: [.favourite])
|
||||||
print(merged3.count)
|
print(merged3.count)
|
||||||
XCTAssertEqual(merged3, [
|
XCTAssertEqual(merged3, [
|
||||||
group1,
|
group1,
|
||||||
group5,
|
group5,
|
||||||
NotificationGroup(notifications: [likeB, likeB2]),
|
NotificationGroup(notifications: [likeB, likeB2], kind: .favourite),
|
||||||
group3
|
group3
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDontMergeWithUngroupableInBetween() {
|
func testDontMergeWithUngroupableInBetween() {
|
||||||
let group1 = NotificationGroup(notifications: [likeA1])!
|
let group1 = NotificationGroup(notifications: [likeA1], kind: .favourite)!
|
||||||
let group2 = NotificationGroup(notifications: [mentionB])!
|
let group2 = NotificationGroup(notifications: [mentionB], kind: .mention)!
|
||||||
let group3 = NotificationGroup(notifications: [likeA2])!
|
let group3 = NotificationGroup(notifications: [likeA2], kind: .favourite)!
|
||||||
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
let merged = NotificationGroup.mergeGroups(first: [group1, group2], second: [group3], only: [.favourite])
|
||||||
XCTAssertEqual(merged, [
|
XCTAssertEqual(merged, [
|
||||||
NotificationGroup(notifications: [likeA1])!,
|
NotificationGroup(notifications: [likeA1], kind: .favourite)!,
|
||||||
NotificationGroup(notifications: [mentionB])!,
|
NotificationGroup(notifications: [mentionB], kind: .mention)!,
|
||||||
NotificationGroup(notifications: [likeA2])!,
|
NotificationGroup(notifications: [likeA2], kind: .favourite)!,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -484,3 +484,11 @@ extension ConversationViewController: StatusBarTappableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ConversationViewController: RefreshableViewController {
|
||||||
|
func refresh() {
|
||||||
|
Task {
|
||||||
|
await refreshContext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -16,15 +16,11 @@ struct TrendingLinkCardView: View {
|
|||||||
let card: Card
|
let card: Card
|
||||||
|
|
||||||
private var imageURL: URL? {
|
private var imageURL: URL? {
|
||||||
if let image = card.image {
|
card.image.flatMap { URL($0) }
|
||||||
URL(image)
|
|
||||||
} else {
|
|
||||||
nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var descriptionText: String {
|
private var descriptionText: String {
|
||||||
var converter = TextConverter(configuration: .init(insertNewlines: false))
|
let converter = TextConverter(configuration: .init(insertNewlines: false))
|
||||||
return converter.convert(html: card.description)
|
return converter.convert(html: card.description)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,6 +151,22 @@ class BaseMainTabBarViewController: UITabBarController, FastAccountSwitcherViewC
|
|||||||
return false
|
return false
|
||||||
#endif // !os(visionOS)
|
#endif // !os(visionOS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Keyboard shortcuts
|
||||||
|
|
||||||
|
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||||
|
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
|
||||||
|
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
|
||||||
|
// we manually delegate to the top view controller if possible.
|
||||||
|
if action == #selector(RefreshableViewController.refresh),
|
||||||
|
let selected = selectedViewController as? NavigationControllerProtocol,
|
||||||
|
let top = selected.topViewController as? RefreshableViewController {
|
||||||
|
return top
|
||||||
|
} else {
|
||||||
|
return super.target(forAction: action, withSender: sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension BaseMainTabBarViewController: TuskerNavigationDelegate {
|
extension BaseMainTabBarViewController: TuskerNavigationDelegate {
|
||||||
|
@ -219,6 +219,19 @@ class MainSplitViewController: UISplitViewController {
|
|||||||
@objc func handleComposeKeyCommand() {
|
@objc func handleComposeKeyCommand() {
|
||||||
compose(editing: nil)
|
compose(editing: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||||
|
// This is a silly workaround for when the sidebar is focused (and therefore first responder), which,
|
||||||
|
// unfortunately, is almost always. Because the content view controller then isn't in the responder chain,
|
||||||
|
// we manually delegate to the top view controller if possible.
|
||||||
|
if action == #selector(RefreshableViewController.refresh),
|
||||||
|
traitCollection.horizontalSizeClass == .regular,
|
||||||
|
let top = secondaryNavController.topViewController as? RefreshableViewController {
|
||||||
|
return top
|
||||||
|
} else {
|
||||||
|
return super.target(forAction: action, withSender: sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,3 +180,9 @@ extension NotificationsPageViewController: StateRestorableViewController {
|
|||||||
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
|
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NotificationsPageViewController: RefreshableViewController {
|
||||||
|
func refresh() {
|
||||||
|
(currentViewController as? RefreshableViewController)?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -75,13 +75,14 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||||||
|
|
||||||
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
dataSource = UITableViewDiffableDataSource(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
||||||
switch item {
|
switch item {
|
||||||
case let .selected(_, instance):
|
case let .selected(_, info):
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
|
||||||
cell.updateUI(instance: instance)
|
cell.updateUI(info: info)
|
||||||
return cell
|
return cell
|
||||||
case let .recommended(instance):
|
case let .recommended(instance):
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: instanceCell, for: indexPath) as! InstanceTableViewCell
|
||||||
cell.updateUI(instance: instance)
|
let info = Info(host: instance.domain, description: instance.description, thumbnail: instance.proxiedThumbnailURL, adult: instance.category == "adult")
|
||||||
|
cell.updateUI(info: info)
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -164,22 +165,20 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = Client(baseURL: url, session: .appDefault)
|
checkSpecificInstance(url: url) { (info) in
|
||||||
let request = Client.getInstanceV1()
|
|
||||||
client.run(request) { (response) in
|
|
||||||
var snapshot = self.dataSource.snapshot()
|
var snapshot = self.dataSource.snapshot()
|
||||||
if snapshot.indexOfSection(.selected) != nil {
|
if snapshot.indexOfSection(.selected) != nil {
|
||||||
snapshot.deleteSections([.selected])
|
snapshot.deleteSections([.selected])
|
||||||
}
|
}
|
||||||
|
|
||||||
if case let .success(instance, _) = response {
|
if let info {
|
||||||
if snapshot.indexOfSection(.recommendedInstances) != nil {
|
if snapshot.indexOfSection(.recommendedInstances) != nil {
|
||||||
snapshot.insertSections([.selected], beforeSection: .recommendedInstances)
|
snapshot.insertSections([.selected], beforeSection: .recommendedInstances)
|
||||||
} else {
|
} else {
|
||||||
snapshot.appendSections([.selected])
|
snapshot.appendSections([.selected])
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot.appendItems([.selected(url, instance)], toSection: .selected)
|
snapshot.appendItems([.selected(url, info)], toSection: .selected)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.dataSource.apply(snapshot) {
|
self.dataSource.apply(snapshot) {
|
||||||
@ -194,6 +193,29 @@ class InstanceSelectorTableViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func checkSpecificInstance(url: URL, completionHandler: @escaping (Info?) -> Void) {
|
||||||
|
let client = Client(baseURL: url, session: .appDefault)
|
||||||
|
let request = Client.getInstanceV1()
|
||||||
|
client.run(request) { response in
|
||||||
|
switch response {
|
||||||
|
case .success(let instance, _):
|
||||||
|
let host = url.host ?? URLComponents(string: instance.uri)?.host ?? instance.uri
|
||||||
|
let info = Info(host: host, description: instance.shortDescription ?? instance.description, thumbnail: instance.thumbnail, adult: false)
|
||||||
|
completionHandler(info)
|
||||||
|
case .failure(_):
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let nodeInfo = try await client.nodeInfo()
|
||||||
|
let info = Info(host: url.host!, description: nodeInfo.metadata.nodeDescription, thumbnail: nil, adult: false)
|
||||||
|
completionHandler(info)
|
||||||
|
} catch {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func loadRecommendedInstances() {
|
private func loadRecommendedInstances() {
|
||||||
InstanceSelector.getInstances(category: nil) { (response) in
|
InstanceSelector.getInstances(category: nil) { (response) in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -312,13 +334,13 @@ extension InstanceSelectorTableViewController {
|
|||||||
case recommendedInstances
|
case recommendedInstances
|
||||||
}
|
}
|
||||||
enum Item: Equatable, Hashable, Sendable {
|
enum Item: Equatable, Hashable, Sendable {
|
||||||
case selected(URL, InstanceV1)
|
case selected(URL, Info)
|
||||||
case recommended(InstanceSelector.Instance)
|
case recommended(InstanceSelector.Instance)
|
||||||
|
|
||||||
static func ==(lhs: Item, rhs: Item) -> Bool {
|
static func ==(lhs: Item, rhs: Item) -> Bool {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.selected(urlA, instanceA), .selected(urlB, instanceB)):
|
case let (.selected(urlA, _), .selected(urlB, _)):
|
||||||
return urlA == urlB && instanceA.uri == instanceB.uri
|
return urlA == urlB
|
||||||
case let (.recommended(a), .recommended(b)):
|
case let (.recommended(a), .recommended(b)):
|
||||||
return a.domain == b.domain
|
return a.domain == b.domain
|
||||||
default:
|
default:
|
||||||
@ -328,16 +350,21 @@ extension InstanceSelectorTableViewController {
|
|||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
case let .selected(url, instance):
|
case let .selected(url, _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(url)
|
hasher.combine(url)
|
||||||
hasher.combine(instance.uri)
|
|
||||||
case let .recommended(instance):
|
case let .recommended(instance):
|
||||||
hasher.combine(1)
|
hasher.combine(1)
|
||||||
hasher.combine(instance.domain)
|
hasher.combine(instance.domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
struct Info: Hashable {
|
||||||
|
let host: String
|
||||||
|
let description: String
|
||||||
|
let thumbnail: URL?
|
||||||
|
let adult: Bool
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension InstanceSelectorTableViewController: UISearchResultsUpdating {
|
extension InstanceSelectorTableViewController: UISearchResultsUpdating {
|
||||||
|
@ -393,3 +393,9 @@ extension ProfileViewController: StatusBarTappableViewController {
|
|||||||
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
|
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ProfileViewController: RefreshableViewController {
|
||||||
|
func refresh() {
|
||||||
|
currentViewController.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -212,3 +212,9 @@ extension TimelinesPageViewController: StateRestorableViewController {
|
|||||||
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TimelinesPageViewController: RefreshableViewController {
|
||||||
|
func refresh() {
|
||||||
|
(currentViewController as? RefreshableViewController)?.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -412,7 +412,7 @@ class AttachmentView: GIFImageView {
|
|||||||
makeBadgeView(text: "ALT")
|
makeBadgeView(text: "ALT")
|
||||||
}
|
}
|
||||||
if badges.contains(.noAlt) {
|
if badges.contains(.noAlt) {
|
||||||
makeBadgeView(text: "No ALT")
|
makeBadgeView(text: "NO ALT")
|
||||||
}
|
}
|
||||||
|
|
||||||
let first = stack.arrangedSubviews.first!
|
let first = stack.arrangedSubviews.first!
|
||||||
|
@ -16,8 +16,7 @@ class InstanceTableViewCell: UITableViewCell {
|
|||||||
@IBOutlet weak var adultLabel: UILabel!
|
@IBOutlet weak var adultLabel: UILabel!
|
||||||
@IBOutlet weak var descriptionTextView: ContentTextView!
|
@IBOutlet weak var descriptionTextView: ContentTextView!
|
||||||
|
|
||||||
var instance: InstanceV1?
|
private var instance: InstanceSelectorTableViewController.Info?
|
||||||
var selectorInstance: InstanceSelector.Instance?
|
|
||||||
|
|
||||||
private var thumbnailTask: Task<Void, Never>?
|
private var thumbnailTask: Task<Void, Never>?
|
||||||
|
|
||||||
@ -44,25 +43,14 @@ class InstanceTableViewCell: UITableViewCell {
|
|||||||
backgroundConfiguration = .appListGroupedCell(for: state)
|
backgroundConfiguration = .appListGroupedCell(for: state)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUI(instance: InstanceSelector.Instance) {
|
func updateUI(info: InstanceSelectorTableViewController.Info) {
|
||||||
self.selectorInstance = instance
|
self.instance = info
|
||||||
self.instance = nil
|
|
||||||
|
|
||||||
domainLabel.text = instance.domain
|
|
||||||
adultLabel.isHidden = instance.category != "adult"
|
|
||||||
descriptionTextView.setBodyTextFromHTML(instance.description)
|
|
||||||
updateThumbnail(url: instance.proxiedThumbnailURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUI(instance: InstanceV1) {
|
|
||||||
self.instance = instance
|
|
||||||
self.selectorInstance = nil
|
|
||||||
|
|
||||||
domainLabel.text = URLComponents(string: instance.uri)?.host ?? instance.uri
|
domainLabel.text = info.host
|
||||||
adultLabel.isHidden = true
|
adultLabel.isHidden = !info.adult
|
||||||
descriptionTextView.setBodyTextFromHTML(instance.shortDescription ?? instance.description)
|
descriptionTextView.setBodyTextFromHTML(info.description)
|
||||||
|
|
||||||
if let thumbnail = instance.thumbnail {
|
if let thumbnail = info.thumbnail {
|
||||||
updateThumbnail(url: thumbnail)
|
updateThumbnail(url: thumbnail)
|
||||||
} else {
|
} else {
|
||||||
thumbnailImageView.image = nil
|
thumbnailImageView.image = nil
|
||||||
@ -85,7 +73,6 @@ class InstanceTableViewCell: UITableViewCell {
|
|||||||
|
|
||||||
thumbnailTask?.cancel()
|
thumbnailTask?.cancel()
|
||||||
instance = nil
|
instance = nil
|
||||||
selectorInstance = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -12,11 +12,7 @@ import SwiftUI
|
|||||||
import SafariServices
|
import SafariServices
|
||||||
|
|
||||||
class ProfileFieldValueView: UIView {
|
class ProfileFieldValueView: UIView {
|
||||||
weak var navigationDelegate: TuskerNavigationDelegate? {
|
weak var navigationDelegate: TuskerNavigationDelegate?
|
||||||
didSet {
|
|
||||||
textView.navigationDelegate = navigationDelegate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let converter = HTMLConverter(
|
private static let converter = HTMLConverter(
|
||||||
font: .preferredFont(forTextStyle: .body),
|
font: .preferredFont(forTextStyle: .body),
|
||||||
@ -28,8 +24,9 @@ class ProfileFieldValueView: UIView {
|
|||||||
|
|
||||||
private let account: AccountMO
|
private let account: AccountMO
|
||||||
private let field: Account.Field
|
private let field: Account.Field
|
||||||
|
private var link: (String, URL)?
|
||||||
|
|
||||||
private let textView = ContentTextView()
|
private let label = EmojiLabel()
|
||||||
private var iconView: UIView?
|
private var iconView: UIView?
|
||||||
|
|
||||||
private var currentTargetedPreview: UITargetedPreview?
|
private var currentTargetedPreview: UITargetedPreview?
|
||||||
@ -42,28 +39,34 @@ class ProfileFieldValueView: UIView {
|
|||||||
|
|
||||||
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
|
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
|
||||||
|
|
||||||
#if os(visionOS)
|
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
|
||||||
textView.linkTextAttributes = [
|
guard value != nil else { return }
|
||||||
.foregroundColor: UIColor.link
|
if self.link == nil {
|
||||||
]
|
self.link = (converted.attributedSubstring(from: range).string, value as! URL)
|
||||||
#else
|
}
|
||||||
textView.linkTextAttributes = [
|
#if os(visionOS)
|
||||||
.foregroundColor: UIColor.tintColor
|
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
|
||||||
]
|
#else
|
||||||
#endif
|
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
|
||||||
textView.backgroundColor = nil
|
#endif
|
||||||
textView.isScrollEnabled = false
|
// the .link attribute in a UILabel always makes the color blue >.>
|
||||||
textView.isSelectable = false
|
converted.removeAttribute(.link, range: range)
|
||||||
textView.isEditable = false
|
}
|
||||||
textView.font = .preferredFont(forTextStyle: .body)
|
|
||||||
updateTextContainerInset()
|
if link != nil {
|
||||||
textView.adjustsFontForContentSizeCategory = true
|
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
|
||||||
textView.attributedText = converted
|
label.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||||
textView.setEmojis(account.emojis, identifier: account.id)
|
label.isUserInteractionEnabled = true
|
||||||
textView.isUserInteractionEnabled = true
|
}
|
||||||
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
||||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
label.numberOfLines = 0
|
||||||
addSubview(textView)
|
label.font = .preferredFont(forTextStyle: .body)
|
||||||
|
label.adjustsFontForContentSizeCategory = true
|
||||||
|
label.attributedText = converted
|
||||||
|
label.setEmojis(account.emojis, identifier: account.id)
|
||||||
|
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(label)
|
||||||
|
|
||||||
let labelTrailingConstraint: NSLayoutConstraint
|
let labelTrailingConstraint: NSLayoutConstraint
|
||||||
|
|
||||||
@ -80,20 +83,20 @@ class ProfileFieldValueView: UIView {
|
|||||||
icon.isPointerInteractionEnabled = true
|
icon.isPointerInteractionEnabled = true
|
||||||
icon.accessibilityLabel = "Verified link"
|
icon.accessibilityLabel = "Verified link"
|
||||||
addSubview(icon)
|
addSubview(icon)
|
||||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
|
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
|
||||||
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
label.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
labelTrailingConstraint,
|
labelTrailingConstraint,
|
||||||
textView.topAnchor.constraint(equalTo: topAnchor),
|
label.topAnchor.constraint(equalTo: topAnchor),
|
||||||
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
label.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,36 +105,37 @@ class ProfileFieldValueView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||||
var size = textView.sizeThatFits(size)
|
var size = label.sizeThatFits(size)
|
||||||
if let iconView {
|
if let iconView {
|
||||||
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
|
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
|
||||||
}
|
}
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
||||||
super.traitCollectionDidChange(previousTraitCollection)
|
|
||||||
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
|
|
||||||
updateTextContainerInset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateTextContainerInset() {
|
|
||||||
// blergh
|
|
||||||
switch traitCollection.preferredContentSizeCategory {
|
|
||||||
case .extraSmall:
|
|
||||||
textView.textContainerInset = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0)
|
|
||||||
case .small:
|
|
||||||
textView.textContainerInset = UIEdgeInsets(top: 3, left: 0, bottom: 0, right: 0)
|
|
||||||
case .medium, .large:
|
|
||||||
textView.textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0)
|
|
||||||
default:
|
|
||||||
textView.textContainerInset = .zero
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setTextAlignment(_ alignment: NSTextAlignment) {
|
func setTextAlignment(_ alignment: NSTextAlignment) {
|
||||||
textView.textAlignment = alignment
|
label.textAlignment = alignment
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHashtagOrURL() -> (Hashtag?, URL)? {
|
||||||
|
guard let (text, url) = link else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if text.starts(with: "#") {
|
||||||
|
return (Hashtag(name: String(text.dropFirst()), url: url), url)
|
||||||
|
} else {
|
||||||
|
return (nil, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func linkTapped() {
|
||||||
|
guard let (hashtag, url) = getHashtagOrURL() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let hashtag {
|
||||||
|
navigationDelegate?.selected(tag: hashtag)
|
||||||
|
} else {
|
||||||
|
navigationDelegate?.selected(url: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func verifiedIconTapped() {
|
@objc private func verifiedIconTapped() {
|
||||||
@ -141,7 +145,7 @@ class ProfileFieldValueView: UIView {
|
|||||||
let view = ProfileFieldVerificationView(
|
let view = ProfileFieldVerificationView(
|
||||||
acct: account.acct,
|
acct: account.acct,
|
||||||
verifiedAt: field.verifiedAt!,
|
verifiedAt: field.verifiedAt!,
|
||||||
linkText: textView.text ?? "",
|
linkText: label.text ?? "",
|
||||||
navigationDelegate: navigationDelegate
|
navigationDelegate: navigationDelegate
|
||||||
)
|
)
|
||||||
let host = UIHostingController(rootView: view)
|
let host = UIHostingController(rootView: view)
|
||||||
@ -165,3 +169,49 @@ class ProfileFieldValueView: UIView {
|
|||||||
navigationDelegate.present(toPresent, animated: true)
|
navigationDelegate.present(toPresent, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
guard let (hashtag, url) = getHashtagOrURL(),
|
||||||
|
let navigationDelegate else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let hashtag {
|
||||||
|
return UIContextMenuConfiguration {
|
||||||
|
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
|
||||||
|
} actionProvider: { _ in
|
||||||
|
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return UIContextMenuConfiguration {
|
||||||
|
let vc = SFSafariViewController(url: url)
|
||||||
|
#if !os(visionOS)
|
||||||
|
vc.preferredControlTintColor = Preferences.shared.accentColor.color
|
||||||
|
#endif
|
||||||
|
return vc
|
||||||
|
} actionProvider: { _ in
|
||||||
|
UIMenu(children: self.actionsForURL(url, source: .view(self)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
|
||||||
|
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
|
||||||
|
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
|
||||||
|
rect.origin.x = 0
|
||||||
|
rect.origin.y = (bounds.height - rect.height) / 2
|
||||||
|
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
|
||||||
|
let preview = UITargetedPreview(view: label, parameters: parameters)
|
||||||
|
currentTargetedPreview = preview
|
||||||
|
return preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
|
||||||
|
return currentTargetedPreview
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
// https://help.apple.com/xcode/#/dev745c5c974
|
// https://help.apple.com/xcode/#/dev745c5c974
|
||||||
|
|
||||||
MARKETING_VERSION = 2024.5
|
MARKETING_VERSION = 2024.5
|
||||||
CURRENT_PROJECT_VERSION = 137
|
CURRENT_PROJECT_VERSION = 141
|
||||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||||
|
|
||||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||||
|
Loading…
x
Reference in New Issue
Block a user