Compare commits
86 Commits
2ccf028bc2
...
18f6445a7c
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 18f6445a7c | |
Shadowfacts | c5f42719a0 | |
Shadowfacts | eb89aec00f | |
Shadowfacts | 61576bce58 | |
Shadowfacts | f7d4737782 | |
Shadowfacts | 3dd0f3a154 | |
Shadowfacts | 145ffbfcf0 | |
Shadowfacts | bcf2a2f026 | |
Shadowfacts | 1358152dec | |
Shadowfacts | 2e2279ba8c | |
Shadowfacts | 60dadf599c | |
Shadowfacts | 90537f9d12 | |
Shadowfacts | 8b0c2f80b6 | |
Shadowfacts | 42423f36db | |
Shadowfacts | 176eb7c011 | |
Shadowfacts | da9ca78a8b | |
Shadowfacts | b470ee6401 | |
Shadowfacts | fccd4e427c | |
Shadowfacts | f25031afd4 | |
Shadowfacts | ca65f84137 | |
Shadowfacts | d4057adf4d | |
Shadowfacts | 007937d2d7 | |
Shadowfacts | 5f040ed390 | |
Shadowfacts | 870d0c8404 | |
Shadowfacts | 47b9ac890a | |
Shadowfacts | 50b84350d9 | |
Shadowfacts | cdc64f1b2c | |
Shadowfacts | 2913098e74 | |
Shadowfacts | ce99352e90 | |
Shadowfacts | 8322d3a36c | |
Shadowfacts | a818457f8c | |
Shadowfacts | 1f6644b703 | |
Shadowfacts | 412c5ee91d | |
Shadowfacts | dcc5f7f716 | |
Shadowfacts | 9fefc9e8f8 | |
Shadowfacts | d1af911241 | |
Shadowfacts | 5abd265195 | |
Shadowfacts | 3cb0f46533 | |
Shadowfacts | c367a2e9f1 | |
Shadowfacts | 3eceffbb6b | |
Shadowfacts | 7c3a00a40d | |
Shadowfacts | 45a90fb4a2 | |
Shadowfacts | 8557e110a8 | |
Shadowfacts | c2232a5e14 | |
Shadowfacts | e6d9a33dbf | |
Shadowfacts | d8fccc8f1b | |
Shadowfacts | 6528070f1c | |
Shadowfacts | 09c6a87e19 | |
Shadowfacts | cd0d8fffcb | |
Shadowfacts | 1b6f0c07fd | |
Shadowfacts | 2f31b50a5b | |
Shadowfacts | cee4e15b06 | |
Shadowfacts | 888f44366c | |
Shadowfacts | c88076eec0 | |
Shadowfacts | afe47437e4 | |
Shadowfacts | 4dc484c3c2 | |
Shadowfacts | 0f2a85b108 | |
Shadowfacts | 5e55ce75c2 | |
Shadowfacts | eec2adbfd9 | |
Shadowfacts | a848f6e425 | |
Shadowfacts | 44896d305e | |
Shadowfacts | 6c70ed4b4e | |
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 9df3c33c6c | |
Shadowfacts | d4e82d6e7a | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | cb3fd43dbd | |
Shadowfacts | 3d15759fb9 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 09999175f7 | |
Shadowfacts | f2a9f890ff | |
Shadowfacts | 093994b474 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 | |
Shadowfacts | 844d4056e3 | |
Shadowfacts | 00ef131bb6 | |
Shadowfacts | d6be6f14dc |
|
@ -1,3 +1,34 @@
|
||||||
|
## 2024.3
|
||||||
|
This update includes a number of bugfixes and performance improvements. See below for a list of fixes.
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix an issue displaying rich text in certain cases
|
||||||
|
- Fix crash when video attachment finishes playing
|
||||||
|
- Fix video attachment thumbnails being flipped on Compose screen
|
||||||
|
- Fix profile header images being blurry
|
||||||
|
- Fix crash when opening push notifications in certain circumstances
|
||||||
|
- Fix certain links in profile fields not being tappable
|
||||||
|
- Fix gifv playback pausing audio from other apps
|
||||||
|
- Fix gifv playback being paused when returning from background
|
||||||
|
- Fix badges on gifv attachments not appearing
|
||||||
|
- Fix excessive network traffic when opening profile pages
|
||||||
|
- Fix controls visibility not matching across attachment gallery pages
|
||||||
|
- Fix add hashtag/instance pinned timeline sheet in Customize Timelines dismissing instantly
|
||||||
|
- Fix Dynamic Type not applying to status content
|
||||||
|
- Fix mention/status push notifications not showing CW
|
||||||
|
- Fix sensitive attachment thumbnails being shown in push notifications
|
||||||
|
- Fix profile moved overlay visual and VoiceOver issues
|
||||||
|
- Fix opening Mastodon remote status links
|
||||||
|
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
||||||
|
- Pleroma/Akkoma: Fix editing attachment descriptions not working
|
||||||
|
- Pixelfed/Firefish: Fix error loading certain accounts
|
||||||
|
- Pixelfed: Fix error loading relationships and follow/block/etc. actions
|
||||||
|
- iPadOS: Fix pointer interactions throughout the app
|
||||||
|
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||||
|
- iPadOS: Fix Cmd+1/etc. removing columns when returning to previous tab
|
||||||
|
- iPadOS: Fix multi-column interface not animating for some actions
|
||||||
|
- iPadOS: Fix selecting search results always adding new column
|
||||||
|
|
||||||
## 2024.2
|
## 2024.2
|
||||||
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
||||||
|
|
||||||
|
|
75
CHANGELOG.md
75
CHANGELOG.md
|
@ -1,6 +1,79 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 2024.3 (124)
|
## 2024.3 (131)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix Cmd+3 not correctly switching to Explore tab
|
||||||
|
|
||||||
|
## 2024.3 (130)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix reply author avatar on Compose screen not being pinned to top when scrolling while typing
|
||||||
|
- Fix crash when dragging between buttons in reblog confirmation alert
|
||||||
|
- Fix potential crash when displaying search results
|
||||||
|
- Mac: Fix Post button not displaying on Compose screen
|
||||||
|
|
||||||
|
## 2024.3 (129)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix excessive network traffic on profile pages
|
||||||
|
- Fix attachment gallery controls visibility not being synced between pages
|
||||||
|
- Fix video attachments not restarting when play pressed while at ends
|
||||||
|
- Fix profile field text being misaligned
|
||||||
|
- Fix at sign in timeline statuses usernames sometimes clipping
|
||||||
|
- Fix add hashtag/instance to Pinned Timelines sheets dismissing immediately when opened
|
||||||
|
- Fix for display name being replaced with incorrect user in certain circumstances
|
||||||
|
- Fix profile moved overlay view appearing behind avatar/header
|
||||||
|
- Fix profile moved view accessibility with VoiceOver
|
||||||
|
- Fix mention/status push notifications not showing content warning
|
||||||
|
- Fix sensitive attachment thumbnails being shown in push notifications
|
||||||
|
- Fix Dynamic Type not applying to status content
|
||||||
|
- Fix expand all option in Conversation not transferring when opening ancestors
|
||||||
|
- Fix not being able to resolve remote Mastodon status links in Conversation screen
|
||||||
|
- Fix status indicator icons overlapping thread links when Dynamic Type is enabled
|
||||||
|
|
||||||
|
## 2024.3 (128)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix selecting poll option playing too much haptic feedback
|
||||||
|
- Fix crash when displaying HTML in certain posts
|
||||||
|
- Fix gifv playback pausing audio from other apps
|
||||||
|
- Fix gifv playback not resuming after returning from background
|
||||||
|
- Fix attachment badges not appearing on gifvs
|
||||||
|
- iPadOS: Fix poll options not having pointer hover effects
|
||||||
|
- iPadOS: Fix haptic feedback not working on new Magic Keyboard
|
||||||
|
- iPadOS: Fix scrubbing video with pointer not letting you click to select position
|
||||||
|
- iPadOS: Fix multi-column navigation not animating when replacing multiple columns
|
||||||
|
|
||||||
|
## 2024.3 (127)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
||||||
|
- Fix profile header images being blurry
|
||||||
|
- Fix dismissing gallery when presented from sheet
|
||||||
|
- Fix potential crash in multi-column interface
|
||||||
|
- Fix crash when opening push notification while sheet presented
|
||||||
|
- Fix being able to block your own domain
|
||||||
|
- Fix links in profile fields with other text not being interactable
|
||||||
|
- Fix excessive CPU use immediately after app launch
|
||||||
|
- Fix timeline failing to load when one status is malformed
|
||||||
|
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
||||||
|
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||||
|
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
||||||
|
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
||||||
|
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
||||||
|
- iPadOS: Fix search token suggestions not having pointer effect
|
||||||
|
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
||||||
|
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
||||||
|
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
||||||
|
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
||||||
|
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
||||||
|
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
||||||
|
|
||||||
|
## 2024.3 (126)
|
||||||
|
Bugfixes:
|
||||||
|
- Fix an issue displaying post HTML in certain edge cases
|
||||||
|
- Fix crash when video attachment playback ends
|
||||||
|
- Fix excessive CPU usage when scrubbing video attachment
|
||||||
|
- Fix video attachment thubmnails being flipped on Compose screen
|
||||||
|
- Pleroma: Fix editing attachment descriptions not working
|
||||||
|
|
||||||
|
## 2024.2 (124)
|
||||||
Features/Improvements:
|
Features/Improvements:
|
||||||
- Add subscription option to Tip Jar
|
- Add subscription option to Tip Jar
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||||
mutableContent.body = notification.body
|
mutableContent.body = notification.body
|
||||||
mutableContent.userInfo["notificationID"] = notification.notificationID
|
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||||
mutableContent.userInfo["accountID"] = accountID
|
mutableContent.userInfo["accountID"] = accountID
|
||||||
|
mutableContent.targetContentIdentifier = accountID
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
await updateNotificationContent(mutableContent, account: account, push: notification)
|
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||||
|
@ -121,7 +122,12 @@ class NotificationService: UNNotificationServiceExtension {
|
||||||
|
|
||||||
let notificationContent: String?
|
let notificationContent: String?
|
||||||
if let status = notification.status {
|
if let status = notification.status {
|
||||||
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
if notification.kind == .mention || notification.kind == .status,
|
||||||
|
!status.spoilerText.isEmpty {
|
||||||
|
notificationContent = "⚠️ \(status.spoilerText)"
|
||||||
|
} else {
|
||||||
|
notificationContent = NotificationService.textConverter.convert(html: status.content)
|
||||||
|
}
|
||||||
} else if notification.kind == .follow || notification.kind == .followRequest {
|
} else if notification.kind == .follow || notification.kind == .followRequest {
|
||||||
notificationContent = nil
|
notificationContent = nil
|
||||||
} else {
|
} else {
|
||||||
|
@ -134,7 +140,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||||
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
|
||||||
// because we risk just fetching the same thing a bunch of times for many senders.
|
// because we risk just fetching the same thing a bunch of times for many senders.
|
||||||
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
|
||||||
let attachment = notification.status?.attachments.first {
|
let status = notification.status,
|
||||||
|
!status.sensitive,
|
||||||
|
let attachment = status.attachments.first {
|
||||||
let url = attachment.previewURL ?? attachment.url
|
let url = attachment.previewURL ?? attachment.url
|
||||||
attachmentDataTask = Task {
|
attachmentDataTask = Task {
|
||||||
do {
|
do {
|
||||||
|
|
|
@ -24,6 +24,8 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>NSExtensionServiceRoleType</key>
|
||||||
|
<string>NSExtensionServiceRoleTypeViewer</string>
|
||||||
<key>NSExtensionActivationRule</key>
|
<key>NSExtensionActivationRule</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
|
||||||
|
|
|
@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
|
||||||
case .video, .gifv:
|
case .video, .gifv:
|
||||||
let asset = AVURLAsset(url: url)
|
let asset = AVURLAsset(url: url)
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
imageGenerator.appliesPreferredTrackTransform = true
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||||
#else
|
#else
|
||||||
|
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
|
||||||
if type.conforms(to: .movie) {
|
if type.conforms(to: .movie) {
|
||||||
let asset = AVURLAsset(url: url)
|
let asset = AVURLAsset(url: url)
|
||||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||||
|
imageGenerator.appliesPreferredTrackTransform = true
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||||
#else
|
#else
|
||||||
|
|
|
@ -333,7 +333,12 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { draftsButton }
|
||||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||||
|
#else
|
||||||
|
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
|
||||||
|
#endif
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
ToolbarItem(placement: .bottomOrnament) {
|
ToolbarItem(placement: .bottomOrnament) {
|
||||||
ControllerView(controller: { controller.toolbarController })
|
ControllerView(controller: { controller.toolbarController })
|
||||||
|
@ -461,20 +466,28 @@ public final class ComposeController: ViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var postButton: some View {
|
private var postOrDraftsButton: some View {
|
||||||
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
|
||||||
Button(action: controller.postStatus) {
|
postButton
|
||||||
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
|
||||||
}
|
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
|
||||||
.disabled(!controller.postButtonEnabled)
|
|
||||||
} else {
|
} else {
|
||||||
Button(action: controller.showDrafts) {
|
draftsButton
|
||||||
Text("Drafts")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var draftsButton: some View {
|
||||||
|
Button(action: controller.showDrafts) {
|
||||||
|
Text("Drafts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var postButton: some View {
|
||||||
|
Button(action: controller.postStatus) {
|
||||||
|
Text(draft.editedStatusID == nil ? "Post" : "Edit")
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
.disabled(!controller.postButtonEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
private var keyboardInset: CGFloat {
|
private var keyboardInset: CGFloat {
|
||||||
|
|
|
@ -16,6 +16,8 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
public static let shared = DraftsPersistentContainer()
|
public static let shared = DraftsPersistentContainer()
|
||||||
|
|
||||||
|
public static var captureError: ((any Error) -> Void)?
|
||||||
|
|
||||||
private static let managedObjectModel: NSManagedObjectModel = {
|
private static let managedObjectModel: NSManagedObjectModel = {
|
||||||
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
|
||||||
return NSManagedObjectModel(contentsOf: url)!
|
return NSManagedObjectModel(contentsOf: url)!
|
||||||
|
@ -39,6 +41,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
loadPersistentStores { _, error in
|
loadPersistentStores { _, error in
|
||||||
if let error {
|
if let error {
|
||||||
|
DraftsPersistentContainer.captureError?(error)
|
||||||
fatalError("Loading persistent store: \(error)")
|
fatalError("Loading persistent store: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,7 @@ extension TextViewCaretScrolling {
|
||||||
|
|
||||||
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
|
||||||
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
|
||||||
|
scrollView.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
self.caretScrollPositionAnimator = animator
|
self.caretScrollPositionAnimator = animator
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
|
|
|
@ -76,13 +76,15 @@ struct ReplyStatusView: View {
|
||||||
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
// once you scroll past the in-reply-to-content, the bottom of the avatar should be pinned to the bottom of the content
|
||||||
offset = min(offset, maxOffset)
|
offset = min(offset, maxOffset)
|
||||||
|
|
||||||
return AvatarImageView(
|
return AvatarContainerRepresentable(offset: offset) {
|
||||||
url: status.account.avatar,
|
AvatarImageView(
|
||||||
size: 50,
|
url: status.account.avatar,
|
||||||
style: controller.config.avatarStyle,
|
size: 50,
|
||||||
fetchAvatar: controller.fetchAvatar
|
style: controller.config.avatarStyle,
|
||||||
)
|
fetchAvatar: controller.fetchAvatar
|
||||||
.offset(x: 0, y: offset)
|
)
|
||||||
|
}
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,3 +96,39 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
|
||||||
value = nextValue()
|
value = nextValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This whole dance is necessary so that the offset can be animatable from
|
||||||
|
// UIKit animations, like TextViewCaretScrolling.
|
||||||
|
private struct AvatarContainerRepresentable<Content: View>: UIViewControllerRepresentable {
|
||||||
|
let offset: CGFloat
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> Controller {
|
||||||
|
Controller(host: UIHostingController(rootView: content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: Controller, context: Context) {
|
||||||
|
uiViewController.host.rootView = content
|
||||||
|
uiViewController.host.view.transform = CGAffineTransform(translationX: 0, y: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This extra layer is necessary because applying a transform to the
|
||||||
|
// representable's VC's view doesn't seem to have an effect.
|
||||||
|
class Controller: UIViewController {
|
||||||
|
let host: UIHostingController<Content>
|
||||||
|
|
||||||
|
init(host: UIHostingController<Content>) {
|
||||||
|
self.host = host
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
addChild(host)
|
||||||
|
host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
view.addSubview(host.view)
|
||||||
|
host.view.frame = view.bounds
|
||||||
|
host.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
||||||
appliedSourceToDestTransform = false
|
appliedSourceToDestTransform = false
|
||||||
}
|
}
|
||||||
|
|
||||||
to.view.frame = container.bounds
|
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||||
|
// is in the window's root presentation.
|
||||||
|
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||||
|
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||||
|
// container causees it to be removed when the transition completes.
|
||||||
|
if to.view.superview == nil {
|
||||||
|
to.view.frame = container.bounds
|
||||||
|
container.addSubview(to.view)
|
||||||
|
}
|
||||||
|
|
||||||
from.view.frame = container.bounds
|
from.view.frame = container.bounds
|
||||||
|
container.addSubview(from.view)
|
||||||
|
|
||||||
let content = itemViewController.takeContent()
|
let content = itemViewController.takeContent()
|
||||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||||
content.view.layer.masksToBounds = true
|
content.view.layer.masksToBounds = true
|
||||||
|
|
||||||
container.addSubview(to.view)
|
|
||||||
container.addSubview(from.view)
|
|
||||||
container.addSubview(content.view)
|
container.addSubview(content.view)
|
||||||
|
|
||||||
content.view.frame = destFrameInContainer
|
content.view.frame = destFrameInContainer
|
||||||
|
|
|
@ -44,6 +44,7 @@ class GalleryItemViewController: UIViewController {
|
||||||
private(set) var scrollAndZoomEnabled = true
|
private(set) var scrollAndZoomEnabled = true
|
||||||
|
|
||||||
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
|
||||||
|
|
||||||
override var prefersHomeIndicatorAutoHidden: Bool {
|
override var prefersHomeIndicatorAutoHidden: Bool {
|
||||||
return !controlsVisible
|
return !controlsVisible
|
||||||
}
|
}
|
||||||
|
@ -227,6 +228,8 @@ class GalleryItemViewController: UIViewController {
|
||||||
updateZoomScale(resetZoom: true)
|
updateZoomScale(resetZoom: true)
|
||||||
}
|
}
|
||||||
centerContent()
|
centerContent()
|
||||||
|
// Ensure the transform is correct if the controls are hidden and their size changed.
|
||||||
|
setControlsVisible(controlsVisible, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
@ -289,10 +292,12 @@ class GalleryItemViewController: UIViewController {
|
||||||
|
|
||||||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
||||||
controlsVisible = visible
|
controlsVisible = visible
|
||||||
|
|
||||||
guard let topControlsView,
|
guard let topControlsView,
|
||||||
let bottomControlsView else {
|
let bottomControlsView else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateControlsViews() {
|
func updateControlsViews() {
|
||||||
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
|
||||||
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
|
||||||
|
|
|
@ -125,6 +125,8 @@ extension GalleryViewController: UIPageViewControllerDataSource {
|
||||||
extension GalleryViewController: UIPageViewControllerDelegate {
|
extension GalleryViewController: UIPageViewControllerDelegate {
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||||
currentItemViewController.content.galleryContentWillDisappear()
|
currentItemViewController.content.galleryContentWillDisappear()
|
||||||
|
let new = pendingViewControllers[0] as! GalleryItemViewController
|
||||||
|
new.setControlsVisible(currentItemViewController.controlsVisible, animated: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||||
|
@ -152,14 +154,21 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
|
||||||
|
|
||||||
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
||||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
#if os(visionOS)
|
||||||
|
return nil
|
||||||
|
#else
|
||||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
|
||||||
return GalleryPresentationAnimationController(sourceView: sourceView)
|
return GalleryPresentationAnimationController(sourceView: sourceView)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||||
|
#if os(visionOS)
|
||||||
|
return nil
|
||||||
|
#else
|
||||||
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
|
||||||
let translation: CGPoint?
|
let translation: CGPoint?
|
||||||
let velocity: CGPoint?
|
let velocity: CGPoint?
|
||||||
|
@ -175,5 +184,6 @@ extension GalleryViewController: UIViewControllerTransitioningDelegate {
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||||
instanceType.isPleroma(.akkoma(nil))
|
instanceType.isPleroma
|
||||||
}
|
}
|
||||||
|
|
||||||
public var composeDirectStatuses: Bool {
|
public var composeDirectStatuses: Bool {
|
||||||
|
@ -217,6 +217,18 @@ public final class InstanceFeatures: ObservableObject {
|
||||||
instanceType.isPleroma
|
instanceType.isPleroma
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var muteNotifications: Bool {
|
||||||
|
!instanceType.isPixelfed
|
||||||
|
}
|
||||||
|
|
||||||
|
public var blockDomains: Bool {
|
||||||
|
!instanceType.isPixelfed
|
||||||
|
}
|
||||||
|
|
||||||
|
public var hideReblogs: Bool {
|
||||||
|
!instanceType.isPixelfed
|
||||||
|
}
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -338,6 +350,14 @@ extension InstanceFeatures {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isPixelfed: Bool {
|
||||||
|
if case .pixelfed = self {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@_spi(InstanceType) public enum MastodonType {
|
@_spi(InstanceType) public enum MastodonType {
|
||||||
|
|
|
@ -42,8 +42,7 @@ public struct Client: Sendable {
|
||||||
} else if let date = iso8601.date(from: str) {
|
} 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 Date(timeIntervalSinceReferenceDate: 0)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -205,8 +204,8 @@ public struct Client: Sendable {
|
||||||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -457,14 +456,13 @@ public struct Client: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timelines
|
// MARK: - Timelines
|
||||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
return timeline.request(range: range)
|
return timeline.request(range: range)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Bookmarks
|
// MARK: - Bookmarks
|
||||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||||
request.range = range
|
request.range = range
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
@ -492,7 +490,7 @@ public struct Client: Sendable {
|
||||||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||||
var parameters: [Parameter] = []
|
var parameters: [Parameter] = []
|
||||||
if let limit {
|
if let limit {
|
||||||
parameters.append("limit" => limit)
|
parameters.append("limit" => limit)
|
||||||
|
|
|
@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||||
|
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||||
self.note = try container.decode(String.self, forKey: .note)
|
self.note = try container.decode(String.self, forKey: .note)
|
||||||
self.url = try container.decode(URL.self, forKey: .url)
|
self.url = try container.decode(URL.self, forKey: .url)
|
||||||
|
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
||||||
return request
|
return request
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||||
"only_media" => onlyMedia,
|
"only_media" => onlyMedia,
|
||||||
"pinned" => pinned,
|
"pinned" => pinned,
|
||||||
"exclude_replies" => excludeReplies,
|
"exclude_replies" => excludeReplies,
|
||||||
|
|
|
@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
||||||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||||
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
// not supported on pixelfed
|
||||||
|
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
||||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||||
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
// not supported on pixelfed
|
||||||
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
|
||||||
|
// not supported on pixelfed
|
||||||
|
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
|
||||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
||||||
|
|
||||||
public struct SearchResults: Decodable, Sendable {
|
public struct SearchResults: Decodable, Sendable {
|
||||||
public let accounts: [Account]
|
public let accounts: [Account]
|
||||||
public let statuses: [Status]
|
public let statuses: [TryDecode<Status>]
|
||||||
public let hashtags: [Hashtag]
|
public let hashtags: [Hashtag]
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
|
@ -32,8 +32,8 @@ extension Timeline {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func request(range: RequestRange) -> Request<[Status]> {
|
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
||||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||||
if case .public(true) = self {
|
if case .public(true) = self {
|
||||||
request.queryParameters.append("local" => true)
|
request.queryParameters.append("local" => true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
//
|
||||||
|
// TryDecode.swift
|
||||||
|
// Pachyderm
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 6/8/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum TryDecode<T: Decodable>: Decodable {
|
||||||
|
case error(String)
|
||||||
|
case value(T)
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
do {
|
||||||
|
self = .value(try T(from: decoder))
|
||||||
|
} catch {
|
||||||
|
self = .error(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var value: T? {
|
||||||
|
if case .value(let value) = self {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TryDecode: Sendable where T: Sendable {
|
||||||
|
}
|
|
@ -104,6 +104,7 @@ class PushManagerImpl: _PushManager {
|
||||||
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
||||||
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
||||||
guard newEndpoint != $0.endpoint else {
|
guard newEndpoint != $0.endpoint else {
|
||||||
|
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
|
||||||
return $0
|
return $0
|
||||||
}
|
}
|
||||||
var copy = $0
|
var copy = $0
|
||||||
|
|
|
@ -75,6 +75,7 @@
|
||||||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||||
|
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */; };
|
||||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
|
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
|
||||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
|
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
|
||||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
|
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
|
||||||
|
@ -234,6 +235,7 @@
|
||||||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */; };
|
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */; };
|
||||||
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */; };
|
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */; };
|
||||||
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */; };
|
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */; };
|
||||||
|
D69F26342C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F26332C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift */; };
|
||||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
|
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
|
||||||
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
|
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
|
||||||
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
|
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
|
||||||
|
@ -258,6 +260,7 @@
|
||||||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
|
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
|
||||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
|
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
|
||||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
|
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
|
||||||
|
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; };
|
||||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
|
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
|
||||||
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
|
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
|
||||||
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
|
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
|
||||||
|
@ -359,6 +362,7 @@
|
||||||
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
|
D6EBF01523C55C0900AE061B /* UIApplication+Scenes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */; };
|
||||||
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
|
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */; };
|
||||||
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
|
D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; };
|
||||||
|
D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */; };
|
||||||
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
|
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; };
|
||||||
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; };
|
||||||
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
|
D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; };
|
||||||
|
@ -506,6 +510,7 @@
|
||||||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||||
|
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveProfileSuggestionService.swift; sourceTree = "<group>"; };
|
||||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
|
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
|
||||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
|
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
|
||||||
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
|
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
|
||||||
|
@ -664,6 +669,7 @@
|
||||||
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = "<group>"; };
|
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = "<group>"; };
|
||||||
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = "<group>"; };
|
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = "<group>"; };
|
||||||
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = "<group>"; };
|
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = "<group>"; };
|
||||||
|
D69F26332C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayAndUserNameLabel.swift; sourceTree = "<group>"; };
|
||||||
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
|
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
|
||||||
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
|
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
|
||||||
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
|
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
|
||||||
|
@ -685,6 +691,7 @@
|
||||||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||||
|
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
|
||||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
|
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
|
||||||
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
@ -799,6 +806,7 @@
|
||||||
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
|
D6EBF01423C55C0900AE061B /* UIApplication+Scenes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+Scenes.swift"; sourceTree = "<group>"; };
|
||||||
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
|
D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISceneSession+MastodonController.swift"; sourceTree = "<group>"; };
|
||||||
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
|
D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = "<group>"; };
|
||||||
|
D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionCoordinator.swift; sourceTree = "<group>"; };
|
||||||
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
|
D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = "<group>"; };
|
||||||
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
|
D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
|
@ -1049,6 +1057,7 @@
|
||||||
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
|
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
|
||||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||||
|
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
|
||||||
);
|
);
|
||||||
path = CoreData;
|
path = CoreData;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1473,7 +1482,9 @@
|
||||||
D6BED1722126661300F02DA0 /* Views */ = {
|
D6BED1722126661300F02DA0 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */,
|
||||||
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */,
|
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */,
|
||||||
|
D69F26332C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift */,
|
||||||
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
|
||||||
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
|
||||||
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
|
||||||
|
@ -1507,7 +1518,6 @@
|
||||||
D641C78B213DD92F004B4513 /* Profile Header */,
|
D641C78B213DD92F004B4513 /* Profile Header */,
|
||||||
D641C78A213DD926004B4513 /* Status */,
|
D641C78A213DD926004B4513 /* Status */,
|
||||||
D64AAE8F26C80DB600FC57FB /* Toast */,
|
D64AAE8F26C80DB600FC57FB /* Toast */,
|
||||||
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */,
|
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1602,6 +1612,7 @@
|
||||||
D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */,
|
D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */,
|
||||||
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
|
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
|
||||||
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
|
||||||
|
D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */,
|
||||||
D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
|
D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
|
||||||
D69261262BB3BA610023152C /* Box.swift */,
|
D69261262BB3BA610023152C /* Box.swift */,
|
||||||
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
D61F75B6293C119700C0B37F /* Filterer.swift */,
|
||||||
|
@ -1757,6 +1768,7 @@
|
||||||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||||
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
|
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
|
||||||
|
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
|
||||||
);
|
);
|
||||||
path = API;
|
path = API;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -2141,6 +2153,7 @@
|
||||||
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
||||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||||
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
|
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
|
||||||
|
D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */,
|
||||||
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
|
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
|
||||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
||||||
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
|
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
|
||||||
|
@ -2347,6 +2360,7 @@
|
||||||
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
|
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
|
||||||
D62D67C52A97D8CD00167EE2 /* MultiColumnNavigationController.swift in Sources */,
|
D62D67C52A97D8CD00167EE2 /* MultiColumnNavigationController.swift in Sources */,
|
||||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
|
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
|
||||||
|
D69F26342C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift in Sources */,
|
||||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
|
||||||
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
|
||||||
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
|
||||||
|
@ -2362,6 +2376,7 @@
|
||||||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
|
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
|
||||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
||||||
|
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
|
||||||
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||||
|
@ -2408,6 +2423,7 @@
|
||||||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||||
|
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
|
||||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||||
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
|
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
@ -2672,7 +2688,8 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=*]" = AppIcon;
|
||||||
|
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xros*]" = "AppIcon-vision";
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
@ -2975,7 +2992,8 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=*]" = AppIcon;
|
||||||
|
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xros*]" = "AppIcon-vision";
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
@ -3006,7 +3024,8 @@
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=*]" = AppIcon;
|
||||||
|
"ASSETCATALOG_COMPILER_APPICON_NAME[sdk=xros*]" = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
@ -3241,7 +3260,7 @@
|
||||||
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
|
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = exactVersion;
|
kind = exactVersion;
|
||||||
version = 0.2.3;
|
version = 0.3.0;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
//
|
||||||
|
// RemoveProfileSuggestionService.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 6/1/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class RemoveProfileSuggestionService {
|
||||||
|
private let accountID: String
|
||||||
|
private let mastodonController: MastodonController
|
||||||
|
private let presenter: any TuskerNavigationDelegate
|
||||||
|
private let completionHandler: @MainActor () -> Void
|
||||||
|
|
||||||
|
init(accountID: String, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate, completionHandler: @MainActor @escaping () -> Void) {
|
||||||
|
self.accountID = accountID
|
||||||
|
self.mastodonController = mastodonController
|
||||||
|
self.presenter = presenter
|
||||||
|
self.completionHandler = completionHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async {
|
||||||
|
let req = Suggestion.remove(accountID: accountID)
|
||||||
|
do {
|
||||||
|
_ = try await mastodonController.run(req)
|
||||||
|
completionHandler()
|
||||||
|
} catch {
|
||||||
|
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: presenter) { toast in
|
||||||
|
toast.dismissToast(animated: true)
|
||||||
|
await self.run()
|
||||||
|
}
|
||||||
|
self.presenter.showToast(configuration: config, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -56,6 +56,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
// make sure the persistent container is initialized on the main thread
|
// make sure the persistent container is initialized on the main thread
|
||||||
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
|
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
|
||||||
|
#if canImport(Sentry)
|
||||||
|
DraftsPersistentContainer.captureError = { SentrySDK.capture(error: $0) }
|
||||||
|
#endif
|
||||||
_ = DraftsPersistentContainer.shared
|
_ = DraftsPersistentContainer.shared
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
@ -185,7 +188,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
let mastodonController = MastodonController.getForAccount(account)
|
let mastodonController = MastodonController.getForAccount(account)
|
||||||
do {
|
do {
|
||||||
let result = try await mastodonController.updatePushSubscription(subscription: $0)
|
let result = try await mastodonController.updatePushSubscription(subscription: $0)
|
||||||
PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)")
|
PushManager.logger.info("Updated push subscription \(result.id, privacy: .public) on \(mastodonController.instanceURL) with endpoint \($0.endpoint, privacy: .public)")
|
||||||
|
PushManager.logger.debug("New push subscription: \(String(describing: result))")
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
|
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
|
||||||
|
@ -289,12 +293,18 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||||
// if the scene is already active, then we animate the account switching if necessary
|
// if the scene is already active, then we animate the account switching if necessary
|
||||||
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
||||||
|
|
||||||
rootViewController.select(route: .notifications, animated: false)
|
rootViewController.select(route: .notifications, animated: false) {
|
||||||
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||||
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
||||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
if #available(iOS 17.0, *) {
|
||||||
|
let request = UISceneSessionActivationRequest(userActivity: activity)
|
||||||
|
UIApplication.shared.activateSceneSession(for: request)
|
||||||
|
} else {
|
||||||
|
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "back.png",
|
||||||
|
"idiom" : "vision",
|
||||||
|
"scale" : "2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 6.4 KiB |
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"filename" : "Front.solidimagestacklayer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename" : "Back.solidimagestacklayer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "front.png",
|
||||||
|
"idiom" : "vision",
|
||||||
|
"scale" : "2x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
//
|
||||||
|
// AudioSessionCoordinator.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 7/8/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
final class AudioSessionCoordinator {
|
||||||
|
static let shared = AudioSessionCoordinator()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
private let queue = DispatchQueue(label: "AudioSessionCoordinator", qos: .userInitiated)
|
||||||
|
|
||||||
|
private var videoCount = 0
|
||||||
|
private var gifvCount = 0
|
||||||
|
|
||||||
|
func beginPlayback(mode: Mode, completionHandler: (() -> Void)? = nil) -> Token {
|
||||||
|
let token = Token(mode: mode)
|
||||||
|
queue.async {
|
||||||
|
switch mode {
|
||||||
|
case .video:
|
||||||
|
self.videoCount += 1
|
||||||
|
case .gifv:
|
||||||
|
self.gifvCount += 1
|
||||||
|
}
|
||||||
|
self.update(completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func endPlayback(token: Token, completionHandler: (() -> Void)? = nil) {
|
||||||
|
// mark the token as consumed, so when it's deinited we don't try to end again
|
||||||
|
token.consumed = true
|
||||||
|
// the enqueued block can't retain token, since it may be being dealloc'd right now
|
||||||
|
let mode = token.mode
|
||||||
|
queue.async {
|
||||||
|
switch mode {
|
||||||
|
case .video:
|
||||||
|
self.videoCount -= 1
|
||||||
|
case .gifv:
|
||||||
|
self.gifvCount -= 1
|
||||||
|
}
|
||||||
|
self.update(completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(completionHandler: (() -> Void)?) {
|
||||||
|
let currentCategory = AVAudioSession.sharedInstance().category
|
||||||
|
if videoCount > 0 {
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} else if gifvCount > 0 {
|
||||||
|
// if we're transitioning from video to gifv, fully deactivate first
|
||||||
|
// in order to let other (music) apps resume, then activate with the
|
||||||
|
// ambient category to "mix" with others
|
||||||
|
if currentCategory == .playback {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
// only gifv modes requested, allow mixing with others
|
||||||
|
try? AVAudioSession.sharedInstance().setCategory(.ambient)
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} else {
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||||
|
}
|
||||||
|
completionHandler?()
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Token {
|
||||||
|
let mode: Mode
|
||||||
|
fileprivate var consumed = false
|
||||||
|
|
||||||
|
init(mode: Mode) {
|
||||||
|
self.mode = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if !consumed {
|
||||||
|
AudioSessionCoordinator.shared.endPlayback(token: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Mode {
|
||||||
|
case video
|
||||||
|
case gifv
|
||||||
|
}
|
||||||
|
}
|
|
@ -96,7 +96,7 @@ final class ImageCache: @unchecked Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetch(url: URL) async -> FetchResult {
|
private func fetch(url: URL) async -> FetchResult {
|
||||||
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
|
guard let (data, _) = try? await URLSession.appDefault.data(from: url) else {
|
||||||
return .none
|
return .none
|
||||||
}
|
}
|
||||||
guard let image = UIImage(data: data) else {
|
guard let image = UIImage(data: data) else {
|
||||||
|
|
|
@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
return context
|
return context
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private var lastRemoteChangeToken: NSPersistentHistoryToken?
|
|
||||||
|
|
||||||
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
||||||
// would need to audit existing uses to make sure everything happens on the main thread
|
// would need to audit existing uses to make sure everything happens on the main thread
|
||||||
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
||||||
|
@ -190,8 +188,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||||
viewContext.name = "View"
|
viewContext.name = "View"
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
if accountInfo != nil {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func save(context: NSManagedObjectContext) {
|
func save(context: NSManagedObjectContext) {
|
||||||
|
@ -521,58 +521,82 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||||
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
guard let accountInfo,
|
||||||
|
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
remoteChangesBackgroundContext.perform {
|
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
|
||||||
defer {
|
self.remoteChangesBackgroundContext.perform {
|
||||||
self.lastRemoteChangeToken = token
|
defer {
|
||||||
|
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
|
||||||
|
}
|
||||||
|
let transactions: [NSPersistentHistoryTransaction]
|
||||||
|
do {
|
||||||
|
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
|
||||||
|
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
|
||||||
|
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
|
||||||
|
} else {
|
||||||
|
logger.error("Unexpectedly non-NSPersistentHistoryResult")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !transactions.isEmpty {
|
||||||
|
self.processPersistentHistoryTransactions(transactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NB: We deliberately do not purge old persistent history.
|
||||||
|
// Doing so causes the CoreData+CloudKit integration to replay all of
|
||||||
|
// the server's changes on initialization, which takes a long time
|
||||||
|
// and produces a bunch of intermediate UI updates we don't want.
|
||||||
}
|
}
|
||||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
|
}
|
||||||
if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult,
|
}
|
||||||
let transactions = result.result as? [NSPersistentHistoryTransaction],
|
|
||||||
!transactions.isEmpty {
|
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
|
||||||
var changedHashtags = false
|
logger.info("Processing \(transactions.count) persistent history transactions")
|
||||||
var changedInstances = false
|
var changedHashtags = false
|
||||||
var changedTimelinePositions = Set<NSManagedObjectID>()
|
var changedInstances = false
|
||||||
var changedAccountPrefs = false
|
var changedTimelinePositions = Set<NSManagedObjectID>()
|
||||||
outer: for transaction in transactions {
|
var changedAccountPrefs = false
|
||||||
for change in transaction.changes ?? [] {
|
outer: for transaction in transactions {
|
||||||
if change.changedObjectID.entity.name == "SavedHashtag" {
|
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
|
||||||
changedHashtags = true
|
for change in transaction.changes ?? [] {
|
||||||
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
if change.changedObjectID.entity.name == "SavedHashtag" {
|
||||||
changedInstances = true
|
changedHashtags = true
|
||||||
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
||||||
changedTimelinePositions.insert(change.changedObjectID)
|
changedInstances = true
|
||||||
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
||||||
changedAccountPrefs = true
|
changedTimelinePositions.insert(change.changedObjectID)
|
||||||
}
|
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
||||||
}
|
changedAccountPrefs = true
|
||||||
}
|
}
|
||||||
// Can't capture vars in concurrently-executing closure
|
}
|
||||||
let hashtags = changedHashtags
|
}
|
||||||
let instances = changedInstances
|
// Can't capture vars in concurrently-executing closure
|
||||||
let timelinePositions = changedTimelinePositions
|
let hashtags = changedHashtags
|
||||||
let accountPrefs = changedAccountPrefs
|
let instances = changedInstances
|
||||||
DispatchQueue.main.async {
|
let timelinePositions = changedTimelinePositions
|
||||||
if hashtags {
|
let accountPrefs = changedAccountPrefs
|
||||||
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
DispatchQueue.main.async {
|
||||||
}
|
if hashtags {
|
||||||
if instances {
|
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
||||||
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
}
|
||||||
}
|
if instances {
|
||||||
for id in timelinePositions {
|
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
||||||
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
|
}
|
||||||
continue
|
for id in timelinePositions {
|
||||||
}
|
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
|
||||||
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
|
continue
|
||||||
timelinePosition.changedRemotely()
|
|
||||||
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
|
||||||
}
|
|
||||||
if accountPrefs {
|
|
||||||
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
|
||||||
|
timelinePosition.changedRemotely()
|
||||||
|
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
||||||
|
}
|
||||||
|
if accountPrefs {
|
||||||
|
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
//
|
||||||
|
// PersistentHistoryTokenStore.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 6/8/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreData
|
||||||
|
import UserAccounts
|
||||||
|
|
||||||
|
struct PersistentHistoryTokenStore {
|
||||||
|
private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore")
|
||||||
|
|
||||||
|
private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:]
|
||||||
|
|
||||||
|
private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist")
|
||||||
|
|
||||||
|
private static func load() throws -> [String: NSPersistentHistoryToken]? {
|
||||||
|
let data = try Data(contentsOf: storeURL)
|
||||||
|
let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data)
|
||||||
|
return unarchived as? [String: NSPersistentHistoryToken]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func save() throws {
|
||||||
|
let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true)
|
||||||
|
try data.write(to: PersistentHistoryTokenStore.storeURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) {
|
||||||
|
queue.async {
|
||||||
|
completion(tokens[account.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) {
|
||||||
|
queue.async {
|
||||||
|
tokens[account.id] = token
|
||||||
|
try? save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
}
|
|
@ -25,8 +25,14 @@ class HTMLConverter {
|
||||||
|
|
||||||
private let converter: AttributedStringConverter<Callbacks>
|
private let converter: AttributedStringConverter<Callbacks>
|
||||||
|
|
||||||
init(font: UIFont, monospaceFont: UIFont, color: UIColor, paragraphStyle: NSParagraphStyle) {
|
init(font: UIFont, monospaceFont: UIFont, fontMetrics: UIFontMetrics, color: UIColor, paragraphStyle: NSParagraphStyle) {
|
||||||
let config = AttributedStringConverterConfiguration(font: font, monospaceFont: monospaceFont, color: color, paragraphStyle: paragraphStyle)
|
let config = AttributedStringConverterConfiguration(
|
||||||
|
font: font,
|
||||||
|
monospaceFont: monospaceFont,
|
||||||
|
fontMetrics: fontMetrics,
|
||||||
|
color: color,
|
||||||
|
paragraphStyle: paragraphStyle
|
||||||
|
)
|
||||||
self.converter = AttributedStringConverter(configuration: config)
|
self.converter = AttributedStringConverter(configuration: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -32,7 +32,7 @@ struct MenuController {
|
||||||
static let sidebarItemKeyCommands: [UIKeyCommand] = [
|
static let sidebarItemKeyCommands: [UIKeyCommand] = [
|
||||||
sidebarCommand(item: .tab(.timelines), command: "1", action: #selector(MainSplitViewController.handleSidebarCommandTimelines)),
|
sidebarCommand(item: .tab(.timelines), command: "1", action: #selector(MainSplitViewController.handleSidebarCommandTimelines)),
|
||||||
sidebarCommand(item: .tab(.notifications), command: "2", action: #selector(MainSplitViewController.handleSidebarCommandNotifications)),
|
sidebarCommand(item: .tab(.notifications), command: "2", action: #selector(MainSplitViewController.handleSidebarCommandNotifications)),
|
||||||
sidebarCommand(item: .explore, command: "3", action: #selector(MainSplitViewController.handleSidebarCommandExplore)),
|
sidebarCommand(item: .tab(.explore), command: "3", action: #selector(MainSplitViewController.handleSidebarCommandExplore)),
|
||||||
sidebarCommand(item: .bookmarks, command: "4", action: #selector(MainSplitViewController.handleSidebarCommandBookmarks)),
|
sidebarCommand(item: .bookmarks, command: "4", action: #selector(MainSplitViewController.handleSidebarCommandBookmarks)),
|
||||||
sidebarCommand(item: .tab(.myProfile), command: "5", action: #selector(MainSplitViewController.handleSidebarCommandMyProfile)),
|
sidebarCommand(item: .tab(.myProfile), command: "5", action: #selector(MainSplitViewController.handleSidebarCommandMyProfile)),
|
||||||
]
|
]
|
||||||
|
|
|
@ -32,8 +32,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
||||||
}
|
}
|
||||||
launchActivity = activity
|
launchActivity = activity
|
||||||
|
|
||||||
let account: UserAccountInfo
|
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||||
|
|
||||||
|
let account: UserAccountInfo
|
||||||
if let activityAccount = UserActivityManager.getAccount(from: activity) {
|
if let activityAccount = UserActivityManager.getAccount(from: activity) {
|
||||||
account = activityAccount
|
account = activityAccount
|
||||||
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {
|
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {
|
||||||
|
|
|
@ -29,6 +29,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||||
|
|
||||||
let account: UserAccountInfo
|
let account: UserAccountInfo
|
||||||
let controller: MastodonController
|
let controller: MastodonController
|
||||||
let draft: Draft?
|
let draft: Draft?
|
||||||
|
|
|
@ -83,7 +83,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
} else {
|
} else {
|
||||||
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
||||||
}
|
}
|
||||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
Task(priority: .userInitiated) {
|
||||||
|
_ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||||
|
@ -191,8 +193,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
|
|
||||||
if let activity = launchActivity {
|
if let activity = launchActivity {
|
||||||
func doRestoreActivity(context: UserActivityHandlingContext) {
|
func doRestoreActivity(context: UserActivityHandlingContext) {
|
||||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
Task(priority: .userInitiated) {
|
||||||
context.finalize(activity: activity)
|
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||||
|
context.finalize(activity: activity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if activity.isStateRestorationActivity {
|
if activity.isStateRestorationActivity {
|
||||||
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
||||||
|
@ -225,7 +229,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
window!.windowScene!.title = account.instanceURL.host!
|
window!.windowScene!.title = account.instanceURL.host!
|
||||||
}
|
}
|
||||||
|
|
||||||
let newRoot = createAppUI()
|
window!.windowScene!.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == %@", account.id)
|
||||||
|
|
||||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||||
let direction: AccountSwitchingContainerViewController.AnimationDirection
|
let direction: AccountSwitchingContainerViewController.AnimationDirection
|
||||||
if animated,
|
if animated,
|
||||||
|
@ -235,9 +240,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
} else {
|
} else {
|
||||||
direction = .none
|
direction = .none
|
||||||
}
|
}
|
||||||
container.setRoot(newRoot, for: account, animating: direction)
|
container.setRoot(createAppUI, for: account, animating: direction)
|
||||||
} else {
|
} else {
|
||||||
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
|
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -248,6 +253,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
||||||
LogoutService(accountInfo: account).run()
|
LogoutService(accountInfo: account).run()
|
||||||
if UserAccountsManager.shared.onboardingComplete {
|
if UserAccountsManager.shared.onboardingComplete {
|
||||||
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
|
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
|
||||||
|
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||||
|
container.removeAccount(account)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window!.rootViewController = createOnboardingUI()
|
window!.rootViewController = createOnboardingUI()
|
||||||
}
|
}
|
||||||
|
|
|
@ -64,9 +64,15 @@ struct AddReactionView: View {
|
||||||
}
|
}
|
||||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
|
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
|
||||||
.searchPresentationToolbarBehaviorIfAvailable()
|
.searchPresentationToolbarBehaviorIfAvailable()
|
||||||
|
#if os(visionOS)
|
||||||
|
.onChange(of: query) {
|
||||||
|
updateFilteredEmojis()
|
||||||
|
}
|
||||||
|
#else
|
||||||
.onChange(of: query) { _ in
|
.onChange(of: query) { _ in
|
||||||
updateFilteredEmojis()
|
updateFilteredEmojis()
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.navigationTitle("Add Reaction")
|
.navigationTitle("Add Reaction")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
@ -166,6 +172,7 @@ private struct AddReactionButton<Label: View>: View {
|
||||||
|
|
||||||
private extension View {
|
private extension View {
|
||||||
@available(iOS, obsoleted: 16.0)
|
@available(iOS, obsoleted: 16.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func mediumPresentationDetentIfAvailable() -> some View {
|
func mediumPresentationDetentIfAvailable() -> some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
|
@ -176,6 +183,7 @@ private extension View {
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS, obsoleted: 17.1)
|
@available(iOS, obsoleted: 17.1)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func searchPresentationToolbarBehaviorIfAvailable() -> some View {
|
func searchPresentationToolbarBehaviorIfAvailable() -> some View {
|
||||||
if #available(iOS 17.1, *) {
|
if #available(iOS 17.1, *) {
|
||||||
|
|
|
@ -364,7 +364,9 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
|
||||||
conv.showStatusesAutomatically = showStatusesAutomatically
|
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||||
show(conv)
|
show(conv)
|
||||||
} else {
|
} else {
|
||||||
selected(status: id, state: state.copy())
|
let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController)
|
||||||
|
conv.showStatusesAutomatically = showStatusesAutomatically
|
||||||
|
show(conv)
|
||||||
}
|
}
|
||||||
case .expandThread(childThreads: let childThreads, inline: _):
|
case .expandThread(childThreads: let childThreads, inline: _):
|
||||||
let indexPathBeforeExpandThread = IndexPath(row: indexPath.row - 1, section: indexPath.section)
|
let indexPathBeforeExpandThread = IndexPath(row: indexPath.row - 1, section: indexPath.section)
|
||||||
|
|
|
@ -221,10 +221,16 @@ class ConversationViewController: UIViewController {
|
||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isLikelyMastodonRemoteStatus(url: url),
|
if isLikelyMastodonRemoteStatus(url: url) {
|
||||||
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
|
var request = URLRequest(url: url)
|
||||||
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
// Mastodon uses an intermediate redirect page for browsers which requires user input that we don't want.
|
||||||
effectiveURL = location
|
request.addValue("application/activity+json", forHTTPHeaderField: "accept")
|
||||||
|
if let (_, response) = try? await URLSession.appDefault.data(for: request, delegate: RedirectBlocker()),
|
||||||
|
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
||||||
|
effectiveURL = location
|
||||||
|
} else {
|
||||||
|
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
|
||||||
}
|
}
|
||||||
|
@ -232,9 +238,14 @@ class ConversationViewController: UIViewController {
|
||||||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||||
do {
|
do {
|
||||||
let (results, _) = try await mastodonController.run(request)
|
let (results, _) = try await mastodonController.run(request)
|
||||||
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else {
|
let statuses = results.statuses.compactMap(\.value)
|
||||||
|
// Don't try to exactly match effective URL because the URL form Mastodon
|
||||||
|
// uses for the ActivityPub redirect doesn't match what's returned by the API.
|
||||||
|
// Instead we just assume that, if only one status was returned, it worked.
|
||||||
|
guard statuses.count == 1 else {
|
||||||
throw UnableToResolveError()
|
throw UnableToResolveError()
|
||||||
}
|
}
|
||||||
|
let status = statuses[0]
|
||||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||||
mode = .localID(status.id)
|
mode = .localID(status.id)
|
||||||
return status.id
|
return status.id
|
||||||
|
|
|
@ -13,7 +13,7 @@ struct CustomizeTimelinesView: View {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CustomizeTimelinesList()
|
CustomizeTimelinesList(pinnedTimelines: mastodonController.accountPreferences.pinnedTimelines)
|
||||||
.environmentObject(mastodonController)
|
.environmentObject(mastodonController)
|
||||||
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
|
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
|
||||||
}
|
}
|
||||||
|
@ -26,6 +26,14 @@ struct CustomizeTimelinesList: View {
|
||||||
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
|
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var deletionError: (any Error)?
|
@State private var deletionError: (any Error)?
|
||||||
|
// store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations
|
||||||
|
@State private var pinnedTimelines: [PinnedTimeline]
|
||||||
|
@State private var isShowingAddHashtagSheet = false
|
||||||
|
@State private var isShowingAddInstanceSheet = false
|
||||||
|
|
||||||
|
init(pinnedTimelines: [PinnedTimeline]) {
|
||||||
|
self.pinnedTimelines = pinnedTimelines
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if #available(iOS 16.0, *) {
|
if #available(iOS 16.0, *) {
|
||||||
|
@ -50,8 +58,12 @@ struct CustomizeTimelinesList: View {
|
||||||
|
|
||||||
private var navigationBody: some View {
|
private var navigationBody: some View {
|
||||||
List {
|
List {
|
||||||
PinnedTimelinesView(accountPreferences: mastodonController.accountPreferences)
|
PinnedTimelinesView(
|
||||||
.appGroupedListRowBackground()
|
pinnedTimelines: $pinnedTimelines,
|
||||||
|
isShowingAddHashtagSheet: $isShowingAddHashtagSheet,
|
||||||
|
isShowingAddInstanceSheet: $isShowingAddInstanceSheet
|
||||||
|
)
|
||||||
|
.appGroupedListRowBackground()
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $preferences.hideReblogsInTimelines) {
|
Toggle(isOn: $preferences.hideReblogsInTimelines) {
|
||||||
|
@ -99,6 +111,12 @@ struct CustomizeTimelinesList: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.modifier(PinnedTimelinesModifier(
|
||||||
|
accountPreferences: mastodonController.accountPreferences,
|
||||||
|
pinnedTimelines: $pinnedTimelines,
|
||||||
|
isShowingAddHashtagSheet: $isShowingAddHashtagSheet,
|
||||||
|
isShowingAddInstanceSheet: $isShowingAddInstanceSheet
|
||||||
|
))
|
||||||
.alertWithData("Error Deleting Filter", data: $deletionError, actions: { _ in
|
.alertWithData("Error Deleting Filter", data: $deletionError, actions: { _ in
|
||||||
Button("OK") {
|
Button("OK") {
|
||||||
self.deletionError = nil
|
self.deletionError = nil
|
||||||
|
|
|
@ -11,17 +11,10 @@ import Pachyderm
|
||||||
|
|
||||||
struct PinnedTimelinesView: View {
|
struct PinnedTimelinesView: View {
|
||||||
@EnvironmentObject private var mastodonController: MastodonController
|
@EnvironmentObject private var mastodonController: MastodonController
|
||||||
@ObservedObject private var accountPreferences: AccountPreferences
|
|
||||||
|
|
||||||
@State private var isShowingAddHashtagSheet = false
|
@Binding var pinnedTimelines: [PinnedTimeline]
|
||||||
@State private var isShowingAddInstanceSheet = false
|
@Binding var isShowingAddHashtagSheet: Bool
|
||||||
// store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations
|
@Binding var isShowingAddInstanceSheet: Bool
|
||||||
@State private var pinnedTimelines: [PinnedTimeline]
|
|
||||||
|
|
||||||
init(accountPreferences: AccountPreferences) {
|
|
||||||
self.accountPreferences = accountPreferences
|
|
||||||
self.pinnedTimelines = accountPreferences.pinnedTimelines
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Section {
|
Section {
|
||||||
|
@ -110,42 +103,53 @@ struct PinnedTimelinesView: View {
|
||||||
} header: {
|
} header: {
|
||||||
Text("Pinned Timelines")
|
Text("Pinned Timelines")
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
|
}
|
||||||
#if os(visionOS)
|
}
|
||||||
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
struct PinnedTimelinesModifier: ViewModifier {
|
||||||
#else
|
let accountPreferences: AccountPreferences
|
||||||
if #available(iOS 16.0, *) {
|
@Binding var pinnedTimelines: [PinnedTimeline]
|
||||||
|
@Binding var isShowingAddHashtagSheet: Bool
|
||||||
|
@Binding var isShowingAddInstanceSheet: Bool
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
|
||||||
|
#if os(visionOS)
|
||||||
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
} else {
|
#else
|
||||||
AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines)
|
if #available(iOS 16.0, *) {
|
||||||
|
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
} else {
|
||||||
|
AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines)
|
||||||
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
})
|
||||||
|
.sheet(isPresented: $isShowingAddInstanceSheet, content: {
|
||||||
|
AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
.edgesIgnoringSafeArea(.bottom)
|
||||||
|
})
|
||||||
|
.onReceive(accountPreferences.publisher(for: \.pinnedTimelinesData)) { _ in
|
||||||
|
if pinnedTimelines != accountPreferences.pinnedTimelines {
|
||||||
|
pinnedTimelines = accountPreferences.pinnedTimelines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if os(visionOS)
|
||||||
|
.onChange(of: pinnedTimelines) {
|
||||||
|
if accountPreferences.pinnedTimelines != pinnedTimelines {
|
||||||
|
accountPreferences.pinnedTimelines = pinnedTimelines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
.onChange(of: pinnedTimelines) { newValue in
|
||||||
|
if accountPreferences.pinnedTimelines != newValue {
|
||||||
|
accountPreferences.pinnedTimelines = newValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
})
|
|
||||||
.sheet(isPresented: $isShowingAddInstanceSheet, content: {
|
|
||||||
AddInstancePinnedTimelineView(pinnedTimelines: $pinnedTimelines)
|
|
||||||
.edgesIgnoringSafeArea(.bottom)
|
|
||||||
})
|
|
||||||
.onReceive(accountPreferences.publisher(for: \.pinnedTimelinesData)) { _ in
|
|
||||||
if pinnedTimelines != accountPreferences.pinnedTimelines {
|
|
||||||
pinnedTimelines = accountPreferences.pinnedTimelines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#if os(visionOS)
|
|
||||||
.onChange(of: pinnedTimelines) {
|
|
||||||
if accountPreferences.pinnedTimelines != pinnedTimelines {
|
|
||||||
accountPreferences.pinnedTimelines = pinnedTimelines
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
.onChange(of: pinnedTimelines) { newValue in
|
|
||||||
if accountPreferences.pinnedTimelines != newValue {
|
|
||||||
accountPreferences.pinnedTimelines = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
||||||
|
|
||||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||||
resultsController.exploreNavigationController = self.navigationController!
|
resultsController.exploreNavigationController = self.navigationController!
|
||||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||||
definesPresentationContext = true
|
definesPresentationContext = true
|
||||||
|
|
||||||
navigationItem.searchController = searchController
|
navigationItem.searchController = searchController
|
||||||
|
|
|
@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
|
||||||
|
|
||||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||||
resultsController.exploreNavigationController = self.navigationController
|
resultsController.exploreNavigationController = self.navigationController
|
||||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||||
searchController.obscuresBackgroundDuringPresentation = true
|
searchController.obscuresBackgroundDuringPresentation = true
|
||||||
searchController.hidesNavigationBarDuringPresentation = false
|
searchController.hidesNavigationBarDuringPresentation = false
|
||||||
definesPresentationContext = true
|
definesPresentationContext = true
|
||||||
|
|
|
@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
|
||||||
return UIContextMenuConfiguration {
|
return UIContextMenuConfiguration {
|
||||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||||
} actionProvider: { _ in
|
} actionProvider: { _ in
|
||||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in
|
||||||
|
let service = RemoveProfileSuggestionService(accountID: id, mastodonController: self.mastodonController, presenter: self) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
var snapshot = self.dataSource.snapshot()
|
||||||
|
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||||
|
snapshot.deleteItems([.account(id, .global)])
|
||||||
|
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await service.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
||||||
private func loadTrendingStatuses() async {
|
private func loadTrendingStatuses() async {
|
||||||
let statuses: [Status]
|
let statuses: [Status]
|
||||||
do {
|
do {
|
||||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
|
||||||
} catch {
|
} catch {
|
||||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|
|
@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
||||||
let linksReq = Client.getTrendingLinks(limit: 10)
|
let linksReq = Client.getTrendingLinks(limit: 10)
|
||||||
async let links = try? mastodonController.run(linksReq).0
|
async let links = try? mastodonController.run(linksReq).0
|
||||||
let statusesReq = Client.getTrendingStatuses(limit: 10)
|
let statusesReq = Client.getTrendingStatuses(limit: 10)
|
||||||
async let statuses = try? mastodonController.run(statusesReq).0
|
async let statuses = try? mastodonController.run(statusesReq).0.compactMap(\.value)
|
||||||
|
|
||||||
if let links = await links {
|
if let links = await links {
|
||||||
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
|
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
|
||||||
|
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
|
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||||
|
|
||||||
|
@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func removeProfileSuggestion(accountID: String) async {
|
private func removeProfileSuggestion(accountID: String) async {
|
||||||
let req = Suggestion.remove(accountID: accountID)
|
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in
|
||||||
do {
|
guard let self else { return }
|
||||||
_ = try await mastodonController.run(req)
|
var snapshot = self.dataSource.snapshot()
|
||||||
var snapshot = dataSource.snapshot()
|
|
||||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||||
snapshot.deleteItems([.account(accountID, .global)])
|
snapshot.deleteItems([.account(accountID, .global)])
|
||||||
await apply(snapshot: snapshot)
|
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
} catch {
|
|
||||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
_ = await self.removeProfileSuggestion(accountID: accountID)
|
|
||||||
}
|
|
||||||
self.showToast(configuration: config, animated: true)
|
|
||||||
}
|
}
|
||||||
|
await service.run()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -204,14 +204,19 @@ class FastAccountSwitcherViewController: UIViewController {
|
||||||
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
|
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
|
||||||
switch recognizer.state {
|
switch recognizer.state {
|
||||||
case .began:
|
case .began:
|
||||||
|
show()
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
if #available(iOS 17.5, *) {
|
||||||
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
UIImpactFeedbackGenerator(style: .heavy, view: view).impactOccurred(at: CGPoint(x: view.bounds.midX, y: view.bounds.midY))
|
||||||
|
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator(view: view)
|
||||||
|
} else {
|
||||||
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||||
|
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||||
|
}
|
||||||
selectionChangedFeedbackGenerator?.prepare()
|
selectionChangedFeedbackGenerator?.prepare()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
show()
|
|
||||||
|
|
||||||
case .changed:
|
case .changed:
|
||||||
let location = recognizer.location(in: view)
|
let location = recognizer.location(in: view)
|
||||||
|
|
||||||
|
@ -260,7 +265,11 @@ class FastAccountSwitcherViewController: UIViewController {
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
if hapticFeedback {
|
if hapticFeedback {
|
||||||
selectionChangedFeedbackGenerator?.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
selectionChangedFeedbackGenerator?.selectionChanged(at: location)
|
||||||
|
} else {
|
||||||
|
selectionChangedFeedbackGenerator?.selectionChanged()
|
||||||
|
}
|
||||||
selectionChangedFeedbackGenerator?.prepare()
|
selectionChangedFeedbackGenerator?.prepare()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -98,7 +98,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
||||||
case .unknown:
|
case .unknown:
|
||||||
return LoadingGalleryContentViewController(caption: nil) {
|
return LoadingGalleryContentViewController(caption: nil) {
|
||||||
do {
|
do {
|
||||||
let (data, _) = try await URLSession.shared.data(from: attachment.url)
|
let (data, _) = try await URLSession.appDefault.data(from: attachment.url)
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent)
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent)
|
||||||
try data.write(to: url)
|
try data.write(to: url)
|
||||||
return FallbackGalleryNavigationController(url: url)
|
return FallbackGalleryNavigationController(url: url)
|
||||||
|
|
|
@ -120,6 +120,15 @@ class VideoControlsViewController: UIViewController {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let timestampObserverToken {
|
||||||
|
player.removeTimeObserver(timestampObserverToken)
|
||||||
|
}
|
||||||
|
if let scrubberObserverToken {
|
||||||
|
player.removeTimeObserver(scrubberObserverToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
@ -256,15 +265,18 @@ private class VideoScrubbingControl: UIControl {
|
||||||
|
|
||||||
private func updateFillLayerMask() {
|
private func updateFillLayerMask() {
|
||||||
// I don't know where this animation is coming from
|
// I don't know where this animation is coming from
|
||||||
CATransaction.begin()
|
|
||||||
CATransaction.setDisableActions(true)
|
|
||||||
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
|
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
|
||||||
CATransaction.commit()
|
fillMaskLayer.removeAllAnimations()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||||
touchStartLocation = touch.location(in: self)
|
if touch.type == .pencil || touch.type == .indirectPointer {
|
||||||
scrubbingStartFraction = fractionComplete
|
touchStartLocation = .zero
|
||||||
|
scrubbingStartFraction = 0
|
||||||
|
} else {
|
||||||
|
touchStartLocation = touch.location(in: self)
|
||||||
|
scrubbingStartFraction = fractionComplete
|
||||||
|
}
|
||||||
|
|
||||||
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
|
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
|
||||||
animator!.addAnimations {
|
animator!.addAnimations {
|
||||||
|
@ -275,17 +287,28 @@ private class VideoScrubbingControl: UIControl {
|
||||||
sendActions(for: .editingDidBegin)
|
sendActions(for: .editingDidBegin)
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
if #available(iOS 17.5, *) {
|
||||||
|
feedbackGenerator = UIImpactFeedbackGenerator(style: .light, view: self)
|
||||||
|
} else {
|
||||||
|
feedbackGenerator = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
}
|
||||||
feedbackGenerator!.prepare()
|
feedbackGenerator!.prepare()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
updateScrubbing(for: touch)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||||
|
updateScrubbing(for: touch)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateScrubbing(for touch: UITouch) {
|
||||||
guard let touchStartLocation,
|
guard let touchStartLocation,
|
||||||
let scrubbingStartFraction else {
|
let scrubbingStartFraction else {
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
let location = touch.location(in: self)
|
let location = touch.location(in: self)
|
||||||
let translation = CGPoint(x: location.x - touchStartLocation.x, y: location.y - touchStartLocation.y)
|
let translation = CGPoint(x: location.x - touchStartLocation.x, y: location.y - touchStartLocation.y)
|
||||||
|
@ -294,7 +317,11 @@ private class VideoScrubbingControl: UIControl {
|
||||||
let newFractionComplete = max(0, min(1, unclampedFractionComplete))
|
let newFractionComplete = max(0, min(1, unclampedFractionComplete))
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
if newFractionComplete != fractionComplete && (newFractionComplete == 0 || newFractionComplete == 1) {
|
if newFractionComplete != fractionComplete && (newFractionComplete == 0 || newFractionComplete == 1) {
|
||||||
feedbackGenerator!.impactOccurred(intensity: 0.5)
|
if #available(iOS 17.5, *) {
|
||||||
|
feedbackGenerator!.impactOccurred(intensity: 0.5, at: location)
|
||||||
|
} else {
|
||||||
|
feedbackGenerator!.impactOccurred(intensity: 0.5)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
fractionComplete = newFractionComplete
|
fractionComplete = newFractionComplete
|
||||||
|
@ -311,8 +338,6 @@ private class VideoScrubbingControl: UIControl {
|
||||||
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
|
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
|
||||||
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
|
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
|
||||||
|
|
|
@ -29,6 +29,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||||
private var rateObservation: NSKeyValueObservation?
|
private var rateObservation: NSKeyValueObservation?
|
||||||
private var isFirstAppearance = true
|
private var isFirstAppearance = true
|
||||||
private var hideControlsWorkItem: DispatchWorkItem?
|
private var hideControlsWorkItem: DispatchWorkItem?
|
||||||
|
private var audioSessionToken: AudioSessionCoordinator.Token?
|
||||||
|
|
||||||
init(url: URL, caption: String?) {
|
init(url: URL, caption: String?) {
|
||||||
self.url = url
|
self.url = url
|
||||||
|
@ -172,10 +173,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||||
let wasFirstAppearance = isFirstAppearance
|
let wasFirstAppearance = isFirstAppearance
|
||||||
isFirstAppearance = false
|
isFirstAppearance = false
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
|
||||||
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
|
||||||
try? AVAudioSession.sharedInstance().setActive(true)
|
|
||||||
|
|
||||||
if wasFirstAppearance {
|
if wasFirstAppearance {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.player.play()
|
self.player.play()
|
||||||
|
@ -187,8 +185,8 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
||||||
func galleryContentWillDisappear() {
|
func galleryContentWillDisappear() {
|
||||||
player.pause()
|
player.pause()
|
||||||
|
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
if let audioSessionToken {
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -106,6 +106,9 @@ class VideoOverlayViewController: UIViewController {
|
||||||
if player.rate > 0 {
|
if player.rate > 0 {
|
||||||
player.rate = 0
|
player.rate = 0
|
||||||
} else {
|
} else {
|
||||||
|
if player.currentTime() >= player.currentItem!.duration {
|
||||||
|
player.seek(to: .zero)
|
||||||
|
}
|
||||||
#if os(visionOS)
|
#if os(visionOS)
|
||||||
player.play()
|
player.play()
|
||||||
#else
|
#else
|
||||||
|
|
|
@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
private let predicate: (StatusMO) -> Bool
|
private let predicate: (StatusMO) -> Bool
|
||||||
private let predicateTitle: String
|
private let predicateTitle: String
|
||||||
private let request: (RequestRange) -> Request<[Status]>
|
private let request: (RequestRange) -> Request<[TryDecode<Status>]>
|
||||||
|
|
||||||
var collectionView: UICollectionView! {
|
var collectionView: UICollectionView! {
|
||||||
view as? UICollectionView
|
view as? UICollectionView
|
||||||
|
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
||||||
private var newer: RequestRange?
|
private var newer: RequestRange?
|
||||||
private var older: RequestRange?
|
private var older: RequestRange?
|
||||||
|
|
||||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
|
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[TryDecode<Status>]>, mastodonController: MastodonController) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.predicate = predicate
|
self.predicate = predicate
|
||||||
self.predicateTitle = predicateTitle
|
self.predicateTitle = predicateTitle
|
||||||
|
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let req = request(.count(Self.pageSize))
|
let req = request(.count(Self.pageSize))
|
||||||
let (statuses, pagination) = try await mastodonController.run(req)
|
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||||
|
let statuses = tryStatuses.compactMap(\.value)
|
||||||
newer = pagination?.newer
|
newer = pagination?.newer
|
||||||
older = pagination?.older
|
older = pagination?.older
|
||||||
|
|
||||||
|
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let req = request(older.withCount(Self.pageSize))
|
let req = request(older.withCount(Self.pageSize))
|
||||||
let (statuses, pagination) = try await mastodonController.run(req)
|
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||||
|
let statuses = tryStatuses.compactMap(\.value)
|
||||||
self.older = pagination?.older
|
self.older = pagination?.older
|
||||||
|
|
||||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||||
|
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let req = request(newer.withCount(Self.pageSize))
|
let req = request(newer.withCount(Self.pageSize))
|
||||||
let (statuses, pagination) = try await mastodonController.run(req)
|
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||||
|
let statuses = tryStatuses.compactMap(\.value)
|
||||||
self.newer = pagination?.newer
|
self.newer = pagination?.newer
|
||||||
|
|
||||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||||
|
|
|
@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
||||||
private(set) var currentAccountID: String
|
private(set) var currentAccountID: String
|
||||||
private(set) var root: AccountSwitchableViewController
|
private(set) var root: AccountSwitchableViewController
|
||||||
|
|
||||||
private var userActivities: [String: NSUserActivity] = [:]
|
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
|
||||||
|
|
||||||
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
|
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
|
||||||
self.currentAccountID = account.id
|
self.currentAccountID = account.id
|
||||||
|
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
|
||||||
embedChild(root)
|
embedChild(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
override func didReceiveMemoryWarning() {
|
||||||
|
super.didReceiveMemoryWarning()
|
||||||
|
viewControllers = viewControllers.mapValues { (_, activity) in
|
||||||
|
(nil, activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAccount(_ account: UserAccountInfo) {
|
||||||
|
viewControllers.removeValue(forKey: account.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setRoot(_ newRootProvider: () -> AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||||
let oldRoot = self.root
|
let oldRoot = self.root
|
||||||
if direction == .none {
|
if direction == .none {
|
||||||
oldRoot.removeViewAndController()
|
oldRoot.removeViewAndController()
|
||||||
}
|
}
|
||||||
if let activity = oldRoot.stateRestorationActivity() {
|
if let activity = oldRoot.stateRestorationActivity() {
|
||||||
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
|
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
|
||||||
userActivities[currentAccountID] = activity
|
viewControllers[currentAccountID] = (oldRoot, activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
let newRoot: AccountSwitchableViewController
|
||||||
|
if let (existingRoot, activity) = viewControllers.removeValue(forKey: account.id) {
|
||||||
|
if let existingRoot {
|
||||||
|
newRoot = existingRoot
|
||||||
|
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
|
||||||
|
} else {
|
||||||
|
newRoot = newRootProvider()
|
||||||
|
Task(priority: .userInitiated) {
|
||||||
|
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||||
|
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||||
|
_ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||||
|
context.finalize(activity: activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newRoot = newRootProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
self.currentAccountID = account.id
|
self.currentAccountID = account.id
|
||||||
self.root = newRoot
|
self.root = newRoot
|
||||||
embedChild(newRoot)
|
embedChild(newRoot)
|
||||||
|
|
||||||
if let activity = userActivities.removeValue(forKey: account.id) {
|
|
||||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
|
||||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
|
||||||
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
|
||||||
context.finalize(activity: activity)
|
|
||||||
}
|
|
||||||
|
|
||||||
if direction != .none {
|
if direction != .none {
|
||||||
if UIAccessibility.prefersCrossFadeTransitions {
|
if UIAccessibility.prefersCrossFadeTransitions {
|
||||||
newRoot.view.alpha = 0
|
newRoot.view.alpha = 0
|
||||||
|
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// only one edge is affected in each direction, i have no idea why
|
// only one edge is affected in each direction, i have no idea why
|
||||||
|
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
|
||||||
if direction == .upwards {
|
if direction == .upwards {
|
||||||
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
|
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
|
||||||
} else {
|
} else {
|
||||||
|
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
|
||||||
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
|
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
|
||||||
newRoot.view.transform = .identity
|
newRoot.view.transform = .identity
|
||||||
} completion: { (_) in
|
} completion: { (_) in
|
||||||
|
oldRoot.view.transform = .identity
|
||||||
|
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
|
||||||
oldRoot.removeViewAndController()
|
oldRoot.removeViewAndController()
|
||||||
newRoot.view.layer.masksToBounds = false
|
newRoot.view.layer.masksToBounds = false
|
||||||
}
|
}
|
||||||
|
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
||||||
root.compose(editing: draft, animated: animated, isDucked: isDucked)
|
root.compose(editing: draft, animated: animated, isDucked: isDucked)
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(route: TuskerRoute, animated: Bool) {
|
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
root.select(route: route, animated: animated)
|
root.select(route: route, animated: animated, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
|
|
@ -35,8 +35,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
|
||||||
(child as! TuskerRootViewController).getNavigationController()
|
(child as! TuskerRootViewController).getNavigationController()
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(route: TuskerRoute, animated: Bool) {
|
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
|
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
|
|
@ -13,8 +13,8 @@ import Combine
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MainSidebarViewControllerDelegate: AnyObject {
|
protocol MainSidebarViewControllerDelegate: AnyObject {
|
||||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
|
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
|
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
var exploreTabItems: [Item] {
|
var exploreTabItems: [Item] {
|
||||||
var items: [Item] = [.explore, .bookmarks, .favorites]
|
var items: [Item] = [.tab(.explore), .bookmarks, .favorites]
|
||||||
let snapshot = dataSource.snapshot()
|
let snapshot = dataSource.snapshot()
|
||||||
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
|
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
|
||||||
items.append(.list(list))
|
items.append(.list(list))
|
||||||
|
@ -57,7 +57,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var previouslySelectedItem: Item?
|
private var previouslySelectedItem: Item?
|
||||||
var selectedItem: Item? {
|
var selectedItem: Item? {
|
||||||
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
|
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
|
||||||
return nil
|
return nil
|
||||||
|
@ -170,7 +170,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
snapshot.appendItems([
|
snapshot.appendItems([
|
||||||
.tab(.timelines),
|
.tab(.timelines),
|
||||||
.tab(.notifications),
|
.tab(.notifications),
|
||||||
.explore,
|
.tab(.explore),
|
||||||
.bookmarks,
|
.bookmarks,
|
||||||
.favorites,
|
.favorites,
|
||||||
.tab(.myProfile)
|
.tab(.myProfile)
|
||||||
|
@ -261,19 +261,21 @@ class MainSidebarViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func returnToPreviousItem() {
|
private func returnToPreviousItem() {
|
||||||
let item = previouslySelectedItem ?? .tab(.timelines)
|
let oldItem = selectedItem
|
||||||
|
let newItem = previouslySelectedItem ?? .tab(.timelines)
|
||||||
previouslySelectedItem = nil
|
previouslySelectedItem = nil
|
||||||
select(item: item, animated: true)
|
select(item: newItem, animated: true)
|
||||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showAddList() {
|
private func showAddList() {
|
||||||
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
||||||
) }) { list in
|
) }) { list in
|
||||||
|
let oldItem = self.selectedItem
|
||||||
self.select(item: .list(list), animated: false)
|
self.select(item: .list(list), animated: false)
|
||||||
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
||||||
list.presentEditOnAppear = true
|
list.presentEditOnAppear = true
|
||||||
self.sidebarDelegate?.sidebar(self, showViewController: list)
|
self.sidebarDelegate?.sidebar(self, showViewController: list, previousItem: oldItem)
|
||||||
}
|
}
|
||||||
service.run()
|
service.run()
|
||||||
}
|
}
|
||||||
|
@ -300,7 +302,7 @@ class MainSidebarViewController: UIViewController {
|
||||||
return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode, accountID: id)
|
return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode, accountID: id)
|
||||||
case .tab(.compose):
|
case .tab(.compose):
|
||||||
return UserActivityManager.newPostActivity(accountID: id)
|
return UserActivityManager.newPostActivity(accountID: id)
|
||||||
case .explore:
|
case .tab(.explore):
|
||||||
return UserActivityManager.searchActivity(query: nil, accountID: id)
|
return UserActivityManager.searchActivity(query: nil, accountID: id)
|
||||||
case .bookmarks:
|
case .bookmarks:
|
||||||
return UserActivityManager.bookmarksActivity(accountID: id)
|
return UserActivityManager.bookmarksActivity(accountID: id)
|
||||||
|
@ -338,7 +340,7 @@ extension MainSidebarViewController {
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case tab(MainTabBarViewController.Tab)
|
case tab(MainTabBarViewController.Tab)
|
||||||
case explore, bookmarks, favorites
|
case bookmarks, favorites
|
||||||
case listsHeader, list(List), addList
|
case listsHeader, list(List), addList
|
||||||
case savedHashtagsHeader, savedHashtag(String), addSavedHashtag
|
case savedHashtagsHeader, savedHashtag(String), addSavedHashtag
|
||||||
case savedInstancesHeader, savedInstance(URL), addSavedInstance
|
case savedInstancesHeader, savedInstance(URL), addSavedInstance
|
||||||
|
@ -347,8 +349,6 @@ extension MainSidebarViewController {
|
||||||
switch self {
|
switch self {
|
||||||
case let .tab(tab):
|
case let .tab(tab):
|
||||||
return tab.title
|
return tab.title
|
||||||
case .explore:
|
|
||||||
return "Explore"
|
|
||||||
case .bookmarks:
|
case .bookmarks:
|
||||||
return "Bookmarks"
|
return "Bookmarks"
|
||||||
case .favorites:
|
case .favorites:
|
||||||
|
@ -378,8 +378,6 @@ extension MainSidebarViewController {
|
||||||
switch self {
|
switch self {
|
||||||
case let .tab(tab):
|
case let .tab(tab):
|
||||||
return tab.imageName
|
return tab.imageName
|
||||||
case .explore:
|
|
||||||
return "magnifyingglass"
|
|
||||||
case .bookmarks:
|
case .bookmarks:
|
||||||
return "bookmark"
|
return "bookmark"
|
||||||
case .favorites:
|
case .favorites:
|
||||||
|
@ -471,7 +469,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -540,8 +538,9 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
|
||||||
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
|
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
|
||||||
func didSaveInstance(url: URL) {
|
func didSaveInstance(url: URL) {
|
||||||
dismiss(animated: true) {
|
dismiss(animated: true) {
|
||||||
|
let oldItem = self.selectedItem
|
||||||
self.select(item: .savedInstance(url), animated: true)
|
self.select(item: .savedInstance(url), animated: true)
|
||||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
|
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url), previousItem: oldItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -86,7 +86,7 @@ class MainSplitViewController: UISplitViewController {
|
||||||
// don't unnecesarily construct a content VC unless the we're in actually split mode
|
// don't unnecesarily construct a content VC unless the we're in actually split mode
|
||||||
// when we change from compact -> split for the first time, the VC will be transferred anyways
|
// when we change from compact -> split for the first time, the VC will be transferred anyways
|
||||||
if traitCollection.horizontalSizeClass != .compact {
|
if traitCollection.horizontalSizeClass != .compact {
|
||||||
select(item: .tab(.timelines))
|
doSelect(item: .tab(.timelines))
|
||||||
}
|
}
|
||||||
|
|
||||||
if UIDevice.current.userInterfaceIdiom != .mac {
|
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||||
|
@ -149,7 +149,15 @@ class MainSplitViewController: UISplitViewController {
|
||||||
self.setViewController(newNav, for: .secondary)
|
self.setViewController(newNav, for: .secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(item: MainSidebarViewController.Item) {
|
private func select(newItem: MainSidebarViewController.Item, oldItem: MainSidebarViewController.Item?) {
|
||||||
|
if let oldItem,
|
||||||
|
newItem != oldItem {
|
||||||
|
navigationStacks[oldItem] = secondaryNavController.viewControllers
|
||||||
|
}
|
||||||
|
doSelect(item: newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func doSelect(item: MainSidebarViewController.Item) {
|
||||||
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
|
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSidebarCommandTimelines() {
|
@objc func handleSidebarCommandTimelines() {
|
||||||
|
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
|
||||||
sidebar.select(item: .tab(.timelines), animated: false)
|
sidebar.select(item: .tab(.timelines), animated: false)
|
||||||
select(item: .tab(.timelines))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSidebarCommandNotifications() {
|
@objc func handleSidebarCommandNotifications() {
|
||||||
|
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
|
||||||
sidebar.select(item: .tab(.notifications), animated: false)
|
sidebar.select(item: .tab(.notifications), animated: false)
|
||||||
select(item: .tab(.notifications))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSidebarCommandExplore() {
|
@objc func handleSidebarCommandExplore() {
|
||||||
|
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
|
||||||
sidebar.select(item: .tab(.explore), animated: false)
|
sidebar.select(item: .tab(.explore), animated: false)
|
||||||
select(item: .tab(.explore))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSidebarCommandBookmarks() {
|
@objc func handleSidebarCommandBookmarks() {
|
||||||
|
select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
|
||||||
sidebar.select(item: .bookmarks, animated: false)
|
sidebar.select(item: .bookmarks, animated: false)
|
||||||
select(item: .bookmarks)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func handleSidebarCommandMyProfile() {
|
@objc func handleSidebarCommandMyProfile() {
|
||||||
|
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
|
||||||
sidebar.select(item: .tab(.myProfile), animated: false)
|
sidebar.select(item: .tab(.myProfile), animated: false)
|
||||||
select(item: .tab(.myProfile))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sidebarTapped() {
|
@objc private func sidebarTapped() {
|
||||||
|
@ -271,7 +279,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
$0.1 > $1.1
|
$0.1 > $1.1
|
||||||
}
|
}
|
||||||
if let mostRecentExploreItem = mostRecentExploreItem?.0,
|
if let mostRecentExploreItem = mostRecentExploreItem?.0,
|
||||||
mostRecentExploreItem != .explore {
|
mostRecentExploreItem != .tab(.explore) {
|
||||||
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
|
let exploreNav = tabBarViewController.viewController(for: .explore) as! UINavigationController
|
||||||
// Pop back to root, so we're appending to the Explore VC instead of some other VC
|
// Pop back to root, so we're appending to the Explore VC instead of some other VC
|
||||||
exploreNav.popToRootViewController(animated: false)
|
exploreNav.popToRootViewController(animated: false)
|
||||||
|
@ -284,11 +292,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
case nil:
|
case nil:
|
||||||
break
|
break
|
||||||
|
|
||||||
case let .tab(tab):
|
case .tab(.explore):
|
||||||
// sidebar items that map 1 <-> 1 can be transferred directly
|
|
||||||
tabBarViewController.select(tab: tab, dismissPresented: false)
|
|
||||||
|
|
||||||
case .explore:
|
|
||||||
// Search sidebar item maps to the Explore tab with the search controller/results visible
|
// Search sidebar item maps to the Explore tab with the search controller/results visible
|
||||||
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
|
// The nav stack can't be copied directly, since the split VC uses a different SearchViewController
|
||||||
// so that explore items aren't shown multiple times.
|
// so that explore items aren't shown multiple times.
|
||||||
|
@ -327,10 +331,14 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
|
// Transfer the navigation stack, dropping the search VC, to keep anything the user has opened
|
||||||
transferNavigationStack(from: .explore, to: exploreNav, dropFirst: true, append: true)
|
transferNavigationStack(from: .tab(.explore), to: exploreNav, dropFirst: true, append: true)
|
||||||
|
|
||||||
tabBarViewController.select(tab: .explore, dismissPresented: false)
|
tabBarViewController.select(tab: .explore, dismissPresented: false)
|
||||||
|
|
||||||
|
case let .tab(tab):
|
||||||
|
// sidebar items that map 1 <-> 1 can be transferred directly
|
||||||
|
tabBarViewController.select(tab: tab, dismissPresented: false)
|
||||||
|
|
||||||
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
|
case .bookmarks, .favorites, .list(_), .savedHashtag(_), .savedInstance(_):
|
||||||
tabBarViewController.select(tab: .explore, dismissPresented: false)
|
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
|
// Make sure the Explore VC doesn't show its search bar when it appears, in case the user was previously
|
||||||
|
@ -388,9 +396,9 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
|
// For other items, the 2nd VC in the nav stack determines which sidebar item they map to.
|
||||||
// Search screen has special considerations, all others can be transferred directly.
|
// Search screen has special considerations, all others can be transferred directly.
|
||||||
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
|
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
|
||||||
exploreItem = .explore
|
exploreItem = .tab(.explore)
|
||||||
// reuse the existing VC, if there is one
|
// reuse the existing VC, if there is one
|
||||||
let searchVC = getOrCreateNavigationStack(item: .explore).first! as! InlineTrendsViewController
|
let searchVC = getOrCreateNavigationStack(item: .tab(.explore)).first! as! InlineTrendsViewController
|
||||||
// load the view so that the search controller is accessible
|
// load the view so that the search controller is accessible
|
||||||
searchVC.loadViewIfNeeded()
|
searchVC.loadViewIfNeeded()
|
||||||
let explore = tabNavigationStack.first as! ExploreViewController
|
let explore = tabNavigationStack.first as! ExploreViewController
|
||||||
|
@ -418,16 +426,16 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
case let instanceVC as InstanceTimelineViewController:
|
case let instanceVC as InstanceTimelineViewController:
|
||||||
exploreItem = .savedInstance(instanceVC.instanceURL)
|
exploreItem = .savedInstance(instanceVC.instanceURL)
|
||||||
case is TrendsViewController:
|
case is TrendsViewController:
|
||||||
exploreItem = .explore
|
exploreItem = .tab(.explore)
|
||||||
// skip transferring the ExploreViewController and TrendsViewController
|
// skip transferring the ExploreViewController and TrendsViewController
|
||||||
skipFirst = 2
|
skipFirst = 2
|
||||||
// prepend the InlineTrendsViewController
|
// prepend the InlineTrendsViewController
|
||||||
toPrepend = getOrCreateNavigationStack(item: .explore).first!
|
toPrepend = getOrCreateNavigationStack(item: .tab(.explore)).first!
|
||||||
default:
|
default:
|
||||||
// transfer the navigation stack prepending, the existing explore VC
|
// transfer the navigation stack prepending, the existing explore VC
|
||||||
// if there was other stuff on the explore stack, it will get discarded
|
// if there was other stuff on the explore stack, it will get discarded
|
||||||
toPrepend = getOrCreateNavigationStack(item: .explore).first!
|
toPrepend = getOrCreateNavigationStack(item: .tab(.explore)).first!
|
||||||
exploreItem = .explore
|
exploreItem = .tab(.explore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: skipFirst, prepend: toPrepend)
|
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: skipFirst, prepend: toPrepend)
|
||||||
|
@ -444,12 +452,12 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
||||||
// These tabs map 1 <-> 1 with sidebar items
|
// These tabs map 1 <-> 1 with sidebar items
|
||||||
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
|
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
|
||||||
sidebar.select(item: item, animated: false)
|
sidebar.select(item: item, animated: false)
|
||||||
select(item: item)
|
doSelect(item: item)
|
||||||
|
|
||||||
case .explore:
|
case .explore:
|
||||||
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
|
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
|
||||||
sidebar.select(item: exploreItem!, animated: false)
|
sidebar.select(item: exploreItem!, animated: false)
|
||||||
select(item: exploreItem!)
|
doSelect(item: exploreItem!)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
|
@ -474,16 +482,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
||||||
compose(editing: nil)
|
compose(editing: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
|
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) {
|
||||||
if let previous = sidebar.previouslySelectedItem {
|
select(newItem: item, oldItem: previousItem)
|
||||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
|
||||||
}
|
|
||||||
select(item: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
|
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) {
|
||||||
if let previous = sidebar.previouslySelectedItem {
|
if let previousItem {
|
||||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
navigationStacks[previousItem] = secondaryNavController.viewControllers
|
||||||
}
|
}
|
||||||
secondaryNavController.viewControllers = [viewController]
|
secondaryNavController.viewControllers = [viewController]
|
||||||
}
|
}
|
||||||
|
@ -497,10 +502,10 @@ fileprivate extension MainSidebarViewController.Item {
|
||||||
@MainActor
|
@MainActor
|
||||||
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
|
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .tab(.explore):
|
||||||
|
return InlineTrendsViewController(mastodonController: mastodonController)
|
||||||
case let .tab(tab):
|
case let .tab(tab):
|
||||||
return tab.createViewController(mastodonController)
|
return tab.createViewController(mastodonController)
|
||||||
case .explore:
|
|
||||||
return InlineTrendsViewController(mastodonController: mastodonController)
|
|
||||||
case .bookmarks:
|
case .bookmarks:
|
||||||
return BookmarksViewController(mastodonController: mastodonController)
|
return BookmarksViewController(mastodonController: mastodonController)
|
||||||
case .favorites:
|
case .favorites:
|
||||||
|
@ -537,14 +542,14 @@ extension MainSplitViewController: StateRestorableViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainSplitViewController: TuskerRootViewController {
|
extension MainSplitViewController: TuskerRootViewController {
|
||||||
func select(route: TuskerRoute, animated: Bool) {
|
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||||
guard traitCollection.horizontalSizeClass != .compact else {
|
guard traitCollection.horizontalSizeClass != .compact else {
|
||||||
tabBarViewController?.select(route: route, animated: animated)
|
tabBarViewController?.select(route: route, animated: animated, completion: completion)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard presentedViewController == nil else {
|
guard presentedViewController == nil else {
|
||||||
dismiss(animated: animated) {
|
dismiss(animated: animated) {
|
||||||
self.select(route: route, animated: animated)
|
self.select(route: route, animated: animated, completion: completion)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -557,7 +562,7 @@ extension MainSplitViewController: TuskerRootViewController {
|
||||||
case .myProfile:
|
case .myProfile:
|
||||||
item = .tab(.myProfile)
|
item = .tab(.myProfile)
|
||||||
case .explore:
|
case .explore:
|
||||||
item = .explore
|
item = .tab(.explore)
|
||||||
case .bookmarks:
|
case .bookmarks:
|
||||||
item = .bookmarks
|
item = .bookmarks
|
||||||
case .list(id: let id):
|
case .list(id: let id):
|
||||||
|
@ -567,8 +572,10 @@ extension MainSplitViewController: TuskerRootViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let oldItem = sidebar.selectedItem
|
||||||
sidebar.select(item: item, animated: false)
|
sidebar.select(item: item, animated: false)
|
||||||
select(item: item)
|
select(newItem: item, oldItem: oldItem)
|
||||||
|
completion?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||||
|
@ -609,8 +616,8 @@ extension MainSplitViewController: TuskerRootViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if sidebar.selectedItem != .explore {
|
if sidebar.selectedItem != .tab(.explore) {
|
||||||
select(item: .explore)
|
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {
|
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {
|
||||||
|
|
|
@ -289,7 +289,7 @@ extension MainTabBarViewController: StateRestorableViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension MainTabBarViewController: TuskerRootViewController {
|
extension MainTabBarViewController: TuskerRootViewController {
|
||||||
func select(route: TuskerRoute, animated: Bool) {
|
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||||
switch route {
|
switch route {
|
||||||
case .timelines:
|
case .timelines:
|
||||||
select(tab: .timelines, dismissPresented: true)
|
select(tab: .timelines, dismissPresented: true)
|
||||||
|
@ -310,6 +310,7 @@ extension MainTabBarViewController: TuskerRootViewController {
|
||||||
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
|
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
completion?()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||||
|
|
|
@ -12,7 +12,7 @@ import ComposeUI
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
||||||
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
|
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
|
||||||
func select(route: TuskerRoute, animated: Bool)
|
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?)
|
||||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
||||||
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
||||||
func getNavigationController() -> NavigationControllerProtocol
|
func getNavigationController() -> NavigationControllerProtocol
|
||||||
|
@ -21,33 +21,6 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
|
||||||
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
|
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
|
||||||
}
|
}
|
||||||
|
|
||||||
//extension TuskerRootViewController {
|
|
||||||
// func select(route: NewRoute, animated: Bool) {
|
|
||||||
// doApply(components: route.components, animated: animated)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// private func doApply(components: ArraySlice<RouteComponent>, animated: Bool) {
|
|
||||||
// guard let first = components.first else {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// doApply(component: first, animated: animated) {
|
|
||||||
// self.doApply(components: components.dropFirst(), animated: animated)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// private func doApply(component: RouteComponent, animated: Bool, completion: @escaping () -> Void) {
|
|
||||||
// switch component {
|
|
||||||
// case .topLevelItem(let rootRoute):
|
|
||||||
// select(route: rootRoute)
|
|
||||||
// completion()
|
|
||||||
// case .popToRoot:
|
|
||||||
// _ = getNavigationController().popToRootViewController(animated: animated)
|
|
||||||
// completion()
|
|
||||||
// case .push(<#T##(MastodonController) -> UIViewController#>)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
enum TuskerRoute {
|
enum TuskerRoute {
|
||||||
case timelines
|
case timelines
|
||||||
case notifications
|
case notifications
|
||||||
|
@ -57,33 +30,6 @@ enum TuskerRoute {
|
||||||
case list(id: String)
|
case list(id: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
//struct NewRoute: ExpressibleByArrayLiteral {
|
|
||||||
// let components: [RouteComponent]
|
|
||||||
//
|
|
||||||
// init(arrayLiteral elements: RouteComponent...) {
|
|
||||||
// self.components = elements
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// static var timelines: Self { [.topLevelItem(.timelines)] }
|
|
||||||
// static var explore: Self { [.topLevelItem(.explore)] }
|
|
||||||
// static var myProfile: Self { [.topLevelItem(.myProfile)] }
|
|
||||||
// static var bookmarks: Self { [.topLevelItem(.explore), .push({ BookmarksViewController(mastodonController: $0) })] }
|
|
||||||
// static func profile(accountID: String) -> Self { [.topLevelItem(.timelines), .push({ ProfileViewController(accountID: accountID, mastodonController: $0) })] }
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//enum RouteComponent {
|
|
||||||
// case topLevelItem(RootRoute)
|
|
||||||
// case popToRoot
|
|
||||||
// case push((MastodonController) -> UIViewController)
|
|
||||||
// case present(UIViewController)
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//enum RootRoute {
|
|
||||||
// case timelines
|
|
||||||
// case explore
|
|
||||||
// case myProfile
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol NavigationControllerProtocol: UIViewController {
|
protocol NavigationControllerProtocol: UIViewController {
|
||||||
var viewControllers: [UIViewController] { get set }
|
var viewControllers: [UIViewController] { get set }
|
||||||
|
|
|
@ -78,18 +78,20 @@ struct MuteAccountView: View {
|
||||||
}
|
}
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
Section {
|
if mastodonController.instanceFeatures.muteNotifications {
|
||||||
Toggle(isOn: $muteNotifications) {
|
Section {
|
||||||
Text("Hide notifications from this person")
|
Toggle(isOn: $muteNotifications) {
|
||||||
}
|
Text("Hide notifications from this person")
|
||||||
} footer: {
|
}
|
||||||
if muteNotifications {
|
} footer: {
|
||||||
Text("This user's posts and notifications will be hidden.")
|
if muteNotifications {
|
||||||
} else {
|
Text("This user's posts and notifications will be hidden.")
|
||||||
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
|
} else {
|
||||||
|
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.appGroupedListRowBackground()
|
||||||
}
|
}
|
||||||
.appGroupedListRowBackground()
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker(selection: $duration) {
|
Picker(selection: $duration) {
|
||||||
|
|
|
@ -95,7 +95,7 @@ class NotificationLoadingViewController: UIViewController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let navigationController else {
|
guard let navigationController else {
|
||||||
fatalError("Don't know how to show notification VC outside of navigation controller")
|
fatalError("Don't know how to show notification VC outside of navigation controller: parent is \(parent?.description ?? "<nil>")")
|
||||||
}
|
}
|
||||||
navigationController.viewControllers[navigationController.viewControllers.count - 1] = vc
|
navigationController.viewControllers[navigationController.viewControllers.count - 1] = vc
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,12 +135,11 @@ private struct MockStatusCardView: UIViewRepresentable {
|
||||||
func makeUIView(context: Context) -> StatusCardView {
|
func makeUIView(context: Context) -> StatusCardView {
|
||||||
let view = StatusCardView()
|
let view = StatusCardView()
|
||||||
view.isUserInteractionEnabled = false
|
view.isUserInteractionEnabled = false
|
||||||
let card = Card(
|
let card = StatusCardView.CardData(
|
||||||
url: WebURL("https://vaccor.space/tusker")!,
|
url: WebURL("https://vaccor.space/tusker")!,
|
||||||
title: "Tusker",
|
|
||||||
description: "Tusker is an iOS app for Mastodon",
|
|
||||||
image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
|
image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
|
||||||
kind: .link
|
title: "Tusker",
|
||||||
|
description: "Tusker is an iOS app for Mastodon"
|
||||||
)
|
)
|
||||||
view.updateUI(card: card, sensitive: false)
|
view.updateUI(card: card, sensitive: false)
|
||||||
return view
|
return view
|
||||||
|
|
|
@ -90,7 +90,7 @@ struct PushInstanceSettingsView: View {
|
||||||
let mastodonController = await MastodonController.getForAccount(account)
|
let mastodonController = await MastodonController.getForAccount(account)
|
||||||
do {
|
do {
|
||||||
let result = try await mastodonController.createPushSubscription(subscription: subscription)
|
let result = try await mastodonController.createPushSubscription(subscription: subscription)
|
||||||
PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
|
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) created on \(account.instanceURL) with endpoint \(result.endpoint, privacy: .public)")
|
||||||
self.subscription = subscription
|
self.subscription = subscription
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -112,7 +112,7 @@ struct PushInstanceSettingsView: View {
|
||||||
let mastodonController = await MastodonController.getForAccount(account)
|
let mastodonController = await MastodonController.getForAccount(account)
|
||||||
do {
|
do {
|
||||||
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
|
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
|
||||||
PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)")
|
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) updated on \(account.instanceURL)")
|
||||||
await PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
|
await PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
|
||||||
subscription?.alerts = alerts
|
subscription?.alerts = alerts
|
||||||
subscription?.policy = policy
|
subscription?.policy = policy
|
||||||
|
|
|
@ -84,7 +84,9 @@ struct TipJarView: View {
|
||||||
private var productsView: some View {
|
private var productsView: some View {
|
||||||
if isLoaded {
|
if isLoaded {
|
||||||
VStack {
|
VStack {
|
||||||
supporterSubscriptions
|
if !supporterProducts.isEmpty {
|
||||||
|
supporterSubscriptions
|
||||||
|
}
|
||||||
|
|
||||||
tipPurchases
|
tipPurchases
|
||||||
|
|
||||||
|
@ -419,6 +421,7 @@ private class UbiquitousKeyValueStoreObserver: ObservableObject {
|
||||||
|
|
||||||
private extension View {
|
private extension View {
|
||||||
@available(iOS, obsoleted: 17.0)
|
@available(iOS, obsoleted: 17.0)
|
||||||
|
@available(visionOS 1.0, *)
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func manageSubscriptionsSheetIfAvailable(isPresented: Binding<Bool>, subscriptionGroupID: String) -> some View {
|
func manageSubscriptionsSheetIfAvailable(isPresented: Binding<Bool>, subscriptionGroupID: String) -> some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
|
|
|
@ -249,13 +249,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
|
|
||||||
state = .setupInitialSnapshot
|
state = .setupInitialSnapshot
|
||||||
|
|
||||||
Task {
|
|
||||||
if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])),
|
|
||||||
let relationship = all.first {
|
|
||||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
await tryLoadPinned()
|
await tryLoadPinned()
|
||||||
|
|
||||||
|
@ -297,7 +290,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
@ -513,7 +506,7 @@ extension ProfileStatusesViewController {
|
||||||
extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
private func request(for range: RequestRange = .default) -> Request<[Status]> {
|
private func request(for range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .statuses:
|
case .statuses:
|
||||||
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
||||||
|
@ -526,7 +519,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
||||||
|
|
||||||
func loadInitial() async throws -> [String] {
|
func loadInitial() async throws -> [String] {
|
||||||
let request = request()
|
let request = request()
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
if !statuses.isEmpty {
|
if !statuses.isEmpty {
|
||||||
newer = .after(id: statuses.first!.id, count: nil)
|
newer = .after(id: statuses.first!.id, count: nil)
|
||||||
|
@ -546,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = request(for: newer)
|
let request = request(for: newer)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
throw Error.allCaughtUp
|
throw Error.allCaughtUp
|
||||||
|
@ -567,7 +560,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = request(for: older)
|
let request = request(for: older)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
return []
|
return []
|
||||||
|
|
|
@ -9,6 +9,12 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
import OSLog
|
||||||
|
#if canImport(Sentry)
|
||||||
|
import Sentry
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ProfileViewController")
|
||||||
|
|
||||||
class ProfileViewController: UIViewController, StateRestorableViewController {
|
class ProfileViewController: UIViewController, StateRestorableViewController {
|
||||||
|
|
||||||
|
@ -120,6 +126,19 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
|
||||||
guard let accountID else {
|
guard let accountID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (all, _) = try await mastodonController.run(Client.getRelationships(accounts: [accountID]))
|
||||||
|
if let relationship = all.first {
|
||||||
|
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
logger.error("Error fetching relationship: \(String(describing: error))")
|
||||||
|
#if canImport(Sentry)
|
||||||
|
SentrySDK.capture(error: error)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
||||||
updateAccountUI(account: account)
|
updateAccountUI(account: account)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -53,7 +53,7 @@ struct ReportAddStatusView: View {
|
||||||
.task { @MainActor in
|
.task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true)
|
let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true)
|
||||||
let (statuses, _) = try await mastodonController.run(req)
|
let statuses = try await mastodonController.run(req).0.compactMap(\.value)
|
||||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||||
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
|
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import SwiftUI
|
||||||
private var converter = HTMLConverter(
|
private var converter = HTMLConverter(
|
||||||
font: .preferredFont(forTextStyle: .body),
|
font: .preferredFont(forTextStyle: .body),
|
||||||
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
||||||
|
fontMetrics: .default,
|
||||||
color: .label,
|
color: .label,
|
||||||
paragraphStyle: .default
|
paragraphStyle: .default
|
||||||
)
|
)
|
||||||
|
|
|
@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
|
||||||
super.searchResultsController as! SearchResultsViewController
|
super.searchResultsController as! SearchResultsViewController
|
||||||
}
|
}
|
||||||
|
|
||||||
init(searchResultsController: SearchResultsViewController) {
|
private weak var owner: UIViewController?
|
||||||
|
|
||||||
|
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
|
||||||
|
self.owner = owner
|
||||||
|
|
||||||
super.init(searchResultsController: searchResultsController)
|
super.init(searchResultsController: searchResultsController)
|
||||||
|
|
||||||
searchResultsController.tokenHandler = { [unowned self] token, op in
|
searchResultsController.tokenHandler = { [unowned self] token, op in
|
||||||
|
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
|
||||||
|
var multiColumnNavigationTargetViewController: UIViewController? {
|
||||||
|
owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension UISearchBar {
|
extension UISearchBar {
|
||||||
var searchQueryWithOperators: String {
|
var searchQueryWithOperators: String {
|
||||||
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }
|
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }
|
||||||
|
|
|
@ -266,7 +266,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
guard self.currentQuery == query else { return }
|
guard self.currentQuery == query else { return }
|
||||||
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
|
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
|
||||||
addAccounts(results.accounts)
|
addAccounts(results.accounts)
|
||||||
addStatuses(results.statuses)
|
addStatuses(results.statuses.compactMap(\.value))
|
||||||
} completion: {
|
} completion: {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.showSearchResults(results)
|
self.showSearchResults(results)
|
||||||
|
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
||||||
}
|
}
|
||||||
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
|
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
|
||||||
snapshot.appendSections([.statuses])
|
snapshot.appendSections([.statuses])
|
||||||
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
|
snapshot.appendItems(results.statuses.compactMap(\.value).map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
dataSource.apply(snapshot)
|
dataSource.apply(snapshot)
|
||||||
|
@ -414,7 +414,7 @@ extension SearchResultsViewController {
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case let .hashtag(hashtag):
|
case let .hashtag(hashtag):
|
||||||
hasher.combine("hashtag")
|
hasher.combine("hashtag")
|
||||||
hasher.combine(hashtag.url)
|
hasher.combine(hashtag.name)
|
||||||
case let .status(id, _):
|
case let .status(id, _):
|
||||||
hasher.combine("status")
|
hasher.combine("status")
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
|
|
|
@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
|
||||||
label.topAnchor.constraint(equalTo: contentView.topAnchor),
|
label.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||||
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
addInteraction(UIPointerInteraction(delegate: self))
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
|
||||||
label.text = text
|
label.text = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension SearchTokenSuggestionCollectionViewCell: UIPointerInteractionDelegate {
|
||||||
|
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||||
|
let preview = UITargetedPreview(view: self)
|
||||||
|
return UIPointerStyle(effect: .lift(preview))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
do {
|
do {
|
||||||
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
|
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
|
||||||
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
|
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
|
||||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
|
// TODO: consider replacing undecodable statuses here with items to indicate that to the user
|
||||||
|
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0.compactMap(\.value)
|
||||||
|
|
||||||
let allStatuses = try await [status] + olderStatuses
|
let allStatuses = try await [status] + olderStatuses
|
||||||
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
|
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
|
||||||
|
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
||||||
|
|
||||||
func loadInitial() async throws -> [TimelineItem] {
|
func loadInitial() async throws -> [TimelineItem] {
|
||||||
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize))
|
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize))
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
||||||
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
|
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
throw TimelineViewController.Error.allCaughtUp
|
throw TimelineViewController.Error.allCaughtUp
|
||||||
|
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
||||||
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
|
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: older)
|
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
return []
|
return []
|
||||||
|
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: range)
|
let request = Client.getStatuses(timeline: timeline, range: range)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
return []
|
return []
|
||||||
|
|
|
@ -150,9 +150,16 @@ class CustomAlertActionsView: UIControl {
|
||||||
private var separatorSizeConstraints: [NSLayoutConstraint] = []
|
private var separatorSizeConstraints: [NSLayoutConstraint] = []
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
private let generator = UISelectionFeedbackGenerator()
|
private lazy var generator: UISelectionFeedbackGenerator = {
|
||||||
|
if #available(iOS 17.5, *) {
|
||||||
|
UISelectionFeedbackGenerator(view: self)
|
||||||
|
} else {
|
||||||
|
UISelectionFeedbackGenerator()
|
||||||
|
}
|
||||||
|
}()
|
||||||
#endif
|
#endif
|
||||||
private var currentSelectedActionIndex: Int?
|
private var currentSelectedActionIndex: Int?
|
||||||
|
private var showPressedMenuWorkItem: DispatchWorkItem?
|
||||||
|
|
||||||
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
|
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
|
@ -313,13 +320,42 @@ class CustomAlertActionsView: UIControl {
|
||||||
actionButtons[currentSelectedActionIndex].backgroundColor = nil
|
actionButtons[currentSelectedActionIndex].backgroundColor = nil
|
||||||
}
|
}
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
generator.selectionChanged()
|
if selectedButton != nil {
|
||||||
|
if #available(iOS 17.5, *) {
|
||||||
|
generator.selectionChanged(at: recognizer.location(in: generator.view))
|
||||||
|
} else {
|
||||||
|
generator.selectionChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
if let showPressedMenuWorkItem {
|
||||||
|
showPressedMenuWorkItem.cancel()
|
||||||
|
self.showPressedMenuWorkItem = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSelectedActionIndex = selectedButton?.offset
|
currentSelectedActionIndex = selectedButton?.offset
|
||||||
selectedButton?.element.backgroundColor = .secondarySystemFill
|
selectedButton?.element.backgroundColor = .secondarySystemFill
|
||||||
|
|
||||||
|
if let currentSelectedActionIndex,
|
||||||
|
case .menu(_) = reorderedActions[currentSelectedActionIndex].style,
|
||||||
|
case let button = actionButtons[currentSelectedActionIndex],
|
||||||
|
let interaction = button.contextMenuInteraction,
|
||||||
|
showPressedMenuWorkItem == nil {
|
||||||
|
showPressedMenuWorkItem = DispatchWorkItem {
|
||||||
|
if #available(iOS 17.4, *) {
|
||||||
|
button.performPrimaryAction()
|
||||||
|
} else {
|
||||||
|
let selector = NSSelectorFromString(["Location:", "At", "Menu", "present", "_"].reversed().joined())
|
||||||
|
if interaction.responds(to: selector) {
|
||||||
|
interaction.perform(selector, with: recognizer.location(in: button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250), execute: showPressedMenuWorkItem!)
|
||||||
|
}
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
generator.prepare()
|
generator.prepare()
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -8,17 +8,26 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// View controllers, such as `UISearchController`, that live outside the normal VC hierarchy
|
||||||
|
/// can adopt this protocol to indicate to `MultiColumnNavigationController` the context for
|
||||||
|
/// navigation operations.
|
||||||
|
protocol MultiColumnNavigationCustomTargetProviding {
|
||||||
|
var multiColumnNavigationTargetViewController: UIViewController? { get }
|
||||||
|
}
|
||||||
|
|
||||||
class MultiColumnNavigationController: UIViewController {
|
class MultiColumnNavigationController: UIViewController {
|
||||||
|
|
||||||
private var isManuallyUpdating = false
|
private var _viewControllers: [UIViewController] = []
|
||||||
var viewControllers: [UIViewController] = [] {
|
var viewControllers: [UIViewController] {
|
||||||
didSet {
|
get {
|
||||||
guard isViewLoaded,
|
_viewControllers
|
||||||
!isManuallyUpdating else {
|
}
|
||||||
return
|
set {
|
||||||
|
_viewControllers = newValue
|
||||||
|
if isViewLoaded {
|
||||||
|
updateViews()
|
||||||
|
scrollToEnd(animated: false)
|
||||||
}
|
}
|
||||||
updateViews()
|
|
||||||
scrollToEnd(animated: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,13 +77,13 @@ class MultiColumnNavigationController: UIViewController {
|
||||||
|
|
||||||
private func updateViews() {
|
private func updateViews() {
|
||||||
var i = 0
|
var i = 0
|
||||||
while i < viewControllers.count {
|
while i < _viewControllers.count {
|
||||||
let needsCloseButton = i > 0
|
let needsCloseButton = i > 0
|
||||||
if i <= stackView.arrangedSubviews.count - 1 {
|
if i <= stackView.arrangedSubviews.count - 1 {
|
||||||
let existing = stackView.arrangedSubviews[i] as! ColumnView
|
let existing = stackView.arrangedSubviews[i] as! ColumnView
|
||||||
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton)
|
existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
|
||||||
} else {
|
} else {
|
||||||
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton)
|
let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
|
||||||
stackView.addArrangedSubview(new)
|
stackView.addArrangedSubview(new)
|
||||||
}
|
}
|
||||||
i += 1
|
i += 1
|
||||||
|
@ -92,9 +101,11 @@ class MultiColumnNavigationController: UIViewController {
|
||||||
var index: Int? = nil
|
var index: Int? = nil
|
||||||
var current: UIViewController? = sender
|
var current: UIViewController? = sender
|
||||||
while let c = current {
|
while let c = current {
|
||||||
index = viewControllers.firstIndex(of: c)
|
index = _viewControllers.firstIndex(of: c)
|
||||||
if index != nil {
|
if index != nil {
|
||||||
break
|
break
|
||||||
|
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
|
||||||
|
current = targetProviding.multiColumnNavigationTargetViewController
|
||||||
} else {
|
} else {
|
||||||
current = c.parent
|
current = c.parent
|
||||||
}
|
}
|
||||||
|
@ -112,24 +123,32 @@ class MultiColumnNavigationController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) {
|
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) {
|
||||||
if afterIndex == viewControllers.count - 1 && vcs.count == 1 {
|
if afterIndex == _viewControllers.count - 1 && vcs.count == 1 {
|
||||||
pushViewController(vcs[0], animated: animated)
|
pushViewController(vcs[0], animated: animated)
|
||||||
} else {
|
} else {
|
||||||
viewControllers = Array(viewControllers[...afterIndex]) + vcs
|
_viewControllers = Array(_viewControllers[...afterIndex]) + vcs
|
||||||
|
updateViews()
|
||||||
scrollToEnd(animated: animated)
|
scrollToEnd(animated: animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollToEnd(animated: Bool) {
|
private func scrollToEnd(animated: Bool) {
|
||||||
if viewControllers.isEmpty {
|
if _viewControllers.isEmpty {
|
||||||
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
|
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
|
||||||
} else {
|
} else {
|
||||||
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated)
|
scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollColumnToEnd(columnIndex: Int, animated: Bool) {
|
private func scrollColumnToEnd(columnIndex: Int, animated: Bool) {
|
||||||
|
// Laying out may change the content offset if we have fewer columns than before,
|
||||||
|
// but we want to keep the original offset so that we can animate smoothly to the final one.
|
||||||
|
let origContentOffset = scrollView.contentOffset
|
||||||
scrollView.layoutIfNeeded()
|
scrollView.layoutIfNeeded()
|
||||||
|
if animated {
|
||||||
|
scrollView.contentOffset = origContentOffset
|
||||||
|
}
|
||||||
|
|
||||||
let column = stackView.arrangedSubviews[columnIndex]
|
let column = stackView.arrangedSubviews[columnIndex]
|
||||||
let columnFrame = column.convert(column.bounds, to: scrollView)
|
let columnFrame = column.convert(column.bounds, to: scrollView)
|
||||||
let offset: CGFloat
|
let offset: CGFloat
|
||||||
|
@ -142,14 +161,12 @@ class MultiColumnNavigationController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate func closeColumn(_ vc: UIViewController) {
|
fileprivate func closeColumn(_ vc: UIViewController) {
|
||||||
let index = viewControllers.firstIndex(of: vc)!
|
guard let index = _viewControllers.firstIndex(of: vc),
|
||||||
guard index > 0 else {
|
index > 0 else {
|
||||||
// Can't close the last column
|
// Can't close the last column
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
isManuallyUpdating = true
|
_viewControllers.removeSubrange(index...)
|
||||||
defer { isManuallyUpdating = false }
|
|
||||||
viewControllers.removeSubrange(index...)
|
|
||||||
animateChanges {
|
animateChanges {
|
||||||
for column in self.stackView.arrangedSubviews[index...] {
|
for column in self.stackView.arrangedSubviews[index...] {
|
||||||
column.layer.opacity = 0
|
column.layer.opacity = 0
|
||||||
|
@ -158,7 +175,6 @@ class MultiColumnNavigationController: UIViewController {
|
||||||
} completion: {
|
} completion: {
|
||||||
self.updateViews()
|
self.updateViews()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
|
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
|
||||||
|
@ -173,19 +189,22 @@ class MultiColumnNavigationController: UIViewController {
|
||||||
|
|
||||||
extension MultiColumnNavigationController: NavigationControllerProtocol {
|
extension MultiColumnNavigationController: NavigationControllerProtocol {
|
||||||
var topViewController: UIViewController? {
|
var topViewController: UIViewController? {
|
||||||
viewControllers.last
|
_viewControllers.last
|
||||||
}
|
}
|
||||||
|
|
||||||
func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
||||||
let removed = Array(viewControllers.dropFirst())
|
guard !_viewControllers.isEmpty else {
|
||||||
viewControllers = [viewControllers.first!]
|
return nil
|
||||||
|
}
|
||||||
|
let removed = Array(_viewControllers.dropFirst())
|
||||||
|
_viewControllers = [_viewControllers.first!]
|
||||||
|
updateViews()
|
||||||
|
scrollToEnd(animated: animated)
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushViewController(_ vc: UIViewController, animated: Bool) {
|
func pushViewController(_ vc: UIViewController, animated: Bool) {
|
||||||
isManuallyUpdating = true
|
_viewControllers.append(vc)
|
||||||
defer { isManuallyUpdating = false }
|
|
||||||
viewControllers.append(vc)
|
|
||||||
updateViews()
|
updateViews()
|
||||||
scrollToEnd(animated: animated)
|
scrollToEnd(animated: animated)
|
||||||
if animated {
|
if animated {
|
||||||
|
@ -273,12 +292,17 @@ private class ColumnView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func installCloseBarButton(navigationItem: UINavigationItem) {
|
private func installCloseBarButton(navigationItem: UINavigationItem) {
|
||||||
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
|
func makeItem() -> UIBarButtonItem {
|
||||||
item.accessibilityLabel = "Close Column"
|
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
|
||||||
if navigationItem.leftBarButtonItems != nil {
|
item.accessibilityLabel = "Close Column"
|
||||||
navigationItem.leftBarButtonItems!.insert(item, at: 0)
|
return item
|
||||||
|
}
|
||||||
|
if let leftItems = navigationItem.leftBarButtonItems {
|
||||||
|
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
|
||||||
|
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
navigationItem.leftBarButtonItems = [item]
|
navigationItem.leftBarButtonItems = [makeItem()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -557,11 +557,15 @@ extension MenuActionProvider {
|
||||||
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
|
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
|
||||||
} else {
|
} else {
|
||||||
let image = UIImage(systemName: "circle.slash")
|
let image = UIImage(systemName: "circle.slash")
|
||||||
return UIMenu(title: "Block", image: image, children: [
|
var children = [
|
||||||
UIAction(title: "Cancel", handler: { _ in }),
|
UIAction(title: "Cancel", handler: { _ in }),
|
||||||
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
|
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
|
||||||
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
|
]
|
||||||
])
|
if mastodonController.instanceFeatures.blockDomains,
|
||||||
|
host != mastodonController.account?.url.host {
|
||||||
|
children.append(UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true)))
|
||||||
|
}
|
||||||
|
return UIMenu(title: "Block", image: image, children: children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -592,7 +596,8 @@ extension MenuActionProvider {
|
||||||
@MainActor
|
@MainActor
|
||||||
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
||||||
// don't show action for people that the user isn't following and isn't already hiding reblogs for
|
// don't show action for people that the user isn't following and isn't already hiding reblogs for
|
||||||
guard relationship.following || relationship.showingReblogs else {
|
guard relationship.following || relationship.showingReblogs,
|
||||||
|
mastodonController.instanceFeatures.hideReblogs else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
|
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
|
||||||
|
|
|
@ -50,8 +50,11 @@ extension UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeViewAndController() {
|
func removeViewAndController() {
|
||||||
|
beginAppearanceTransition(false, animated: false)
|
||||||
view.removeFromSuperview()
|
view.removeFromSuperview()
|
||||||
|
willMove(toParent: nil)
|
||||||
removeFromParent()
|
removeFromParent()
|
||||||
|
endAppearanceTransition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,7 +71,7 @@ extension UIView {
|
||||||
|
|
||||||
if layout {
|
if layout {
|
||||||
subview.frame = bounds
|
subview.frame = bounds
|
||||||
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
|
|
@ -44,9 +44,9 @@ enum AppShortcutItem: String, CaseIterable {
|
||||||
}
|
}
|
||||||
switch self {
|
switch self {
|
||||||
case .showHomeTimeline:
|
case .showHomeTimeline:
|
||||||
root.select(route: .timelines, animated: false)
|
root.select(route: .timelines, animated: false, completion: nil)
|
||||||
case .showNotifications:
|
case .showNotifications:
|
||||||
root.select(route: .notifications, animated: false)
|
root.select(route: .notifications, animated: false, completion: nil)
|
||||||
case .composePost:
|
case .composePost:
|
||||||
root.compose(editing: nil, animated: false, isDucked: false)
|
root.compose(editing: nil, animated: false, isDucked: false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,12 +39,13 @@ extension NSUserActivity {
|
||||||
self.userInfo = [
|
self.userInfo = [
|
||||||
"accountID": accountID
|
"accountID": accountID
|
||||||
]
|
]
|
||||||
|
self.targetContentIdentifier = accountID
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func handleResume(manager: UserActivityManager) -> Bool {
|
func handleResume(manager: UserActivityManager) async -> Bool {
|
||||||
guard let type = UserActivityType(rawValue: activityType) else { return false }
|
guard let type = UserActivityType(rawValue: activityType) else { return false }
|
||||||
type.handle(manager)(self)
|
await type.handle(manager)(self)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,8 @@ import ComposeUI
|
||||||
protocol UserActivityHandlingContext {
|
protocol UserActivityHandlingContext {
|
||||||
var isHandoff: Bool { get }
|
var isHandoff: Bool { get }
|
||||||
|
|
||||||
func select(route: TuskerRoute)
|
func select(route: TuskerRoute) async
|
||||||
|
func select(route: TuskerRoute, completion: (() -> Void)?)
|
||||||
func present(_ vc: UIViewController)
|
func present(_ vc: UIViewController)
|
||||||
|
|
||||||
var topViewController: UIViewController? { get }
|
var topViewController: UIViewController? { get }
|
||||||
|
@ -28,6 +29,16 @@ protocol UserActivityHandlingContext {
|
||||||
func finalize(activity: NSUserActivity)
|
func finalize(activity: NSUserActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension UserActivityHandlingContext {
|
||||||
|
func select(route: TuskerRoute) async {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
select(route: route) {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
||||||
let isHandoff: Bool
|
let isHandoff: Bool
|
||||||
let root: TuskerRootViewController
|
let root: TuskerRootViewController
|
||||||
|
@ -35,8 +46,8 @@ struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
||||||
root.getNavigationDelegate()!
|
root.getNavigationDelegate()!
|
||||||
}
|
}
|
||||||
|
|
||||||
func select(route: TuskerRoute) {
|
func select(route: TuskerRoute, completion: (() -> Void)?) {
|
||||||
root.select(route: route, animated: true)
|
root.select(route: route, animated: true, completion: completion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func present(_ vc: UIViewController) {
|
func present(_ vc: UIViewController) {
|
||||||
|
@ -71,9 +82,11 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
||||||
|
|
||||||
var isHandoff: Bool { false }
|
var isHandoff: Bool { false }
|
||||||
|
|
||||||
func select(route: TuskerRoute) {
|
func select(route: TuskerRoute, completion: (() -> Void)?) {
|
||||||
root.select(route: route, animated: false)
|
root.select(route: route, animated: false) {
|
||||||
state = .selectedRoute
|
self.state = .selectedRoute
|
||||||
|
completion?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var topViewController: UIViewController? { root.getNavigationController().topViewController }
|
var topViewController: UIViewController? { root.getNavigationController().topViewController }
|
||||||
|
|
|
@ -133,8 +133,8 @@ class UserActivityManager {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCheckNotifications(activity: NSUserActivity) {
|
func handleCheckNotifications(activity: NSUserActivity) async {
|
||||||
context.select(route: .notifications)
|
await context.select(route: .notifications)
|
||||||
context.popToRoot()
|
context.popToRoot()
|
||||||
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
|
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
|
||||||
notificationsPageController.loadViewIfNeeded()
|
notificationsPageController.loadViewIfNeeded()
|
||||||
|
@ -204,22 +204,22 @@ class UserActivityManager {
|
||||||
return (timeline, positionInfo)
|
return (timeline, positionInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleShowTimeline(activity: NSUserActivity) {
|
func handleShowTimeline(activity: NSUserActivity) async {
|
||||||
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
|
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
|
||||||
|
|
||||||
var timelineVC: TimelineViewController?
|
var timelineVC: TimelineViewController?
|
||||||
if let pinned = PinnedTimeline(timeline: timeline),
|
if let pinned = PinnedTimeline(timeline: timeline),
|
||||||
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
|
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
|
||||||
context.select(route: .timelines)
|
await context.select(route: .timelines)
|
||||||
context.popToRoot()
|
context.popToRoot()
|
||||||
let pageController = context.topViewController as! TimelinesPageViewController
|
let pageController = context.topViewController as! TimelinesPageViewController
|
||||||
pageController.selectTimeline(pinned, animated: false)
|
pageController.selectTimeline(pinned, animated: false)
|
||||||
timelineVC = pageController.currentViewController as? TimelineViewController
|
timelineVC = pageController.currentViewController as? TimelineViewController
|
||||||
} else if case .list(let id) = timeline {
|
} else if case .list(let id) = timeline {
|
||||||
context.select(route: .list(id: id))
|
await context.select(route: .list(id: id))
|
||||||
timelineVC = context.topViewController as? TimelineViewController
|
timelineVC = context.topViewController as? TimelineViewController
|
||||||
} else {
|
} else {
|
||||||
context.select(route: .explore)
|
await context.select(route: .explore)
|
||||||
context.popToRoot()
|
context.popToRoot()
|
||||||
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||||
context.push(timelineVC!)
|
context.push(timelineVC!)
|
||||||
|
@ -249,11 +249,11 @@ class UserActivityManager {
|
||||||
return activity.userInfo?["mainStatusID"] as? String
|
return activity.userInfo?["mainStatusID"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleShowConversation(activity: NSUserActivity) {
|
func handleShowConversation(activity: NSUserActivity) async {
|
||||||
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
|
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.select(route: .timelines)
|
await context.select(route: .timelines)
|
||||||
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
|
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -274,8 +274,8 @@ class UserActivityManager {
|
||||||
return activity.userInfo?["query"] as? String
|
return activity.userInfo?["query"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSearch(activity: NSUserActivity) {
|
func handleSearch(activity: NSUserActivity) async {
|
||||||
context.select(route: .explore)
|
await context.select(route: .explore)
|
||||||
context.popToRoot()
|
context.popToRoot()
|
||||||
|
|
||||||
let searchController: UISearchController
|
let searchController: UISearchController
|
||||||
|
@ -311,8 +311,8 @@ class UserActivityManager {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleBookmarks(activity: NSUserActivity) {
|
func handleBookmarks(activity: NSUserActivity) async {
|
||||||
context.select(route: .bookmarks)
|
await context.select(route: .bookmarks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - My Profile
|
// MARK: - My Profile
|
||||||
|
@ -325,8 +325,8 @@ class UserActivityManager {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMyProfile(activity: NSUserActivity) {
|
func handleMyProfile(activity: NSUserActivity) async {
|
||||||
context.select(route: .myProfile)
|
await context.select(route: .myProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Show Profile
|
// MARK: - Show Profile
|
||||||
|
@ -344,11 +344,11 @@ class UserActivityManager {
|
||||||
return activity.userInfo?["profileID"] as? String
|
return activity.userInfo?["profileID"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleShowProfile(activity: NSUserActivity) {
|
func handleShowProfile(activity: NSUserActivity) async {
|
||||||
guard let accountID = Self.getProfile(from: activity) else {
|
guard let accountID = Self.getProfile(from: activity) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.select(route: .timelines)
|
await context.select(route: .timelines)
|
||||||
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -361,11 +361,11 @@ class UserActivityManager {
|
||||||
return activity
|
return activity
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleShowNotification(activity: NSUserActivity) {
|
func handleShowNotification(activity: NSUserActivity) async {
|
||||||
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
|
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.select(route: .notifications)
|
await context.select(route: .notifications)
|
||||||
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
|
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ enum UserActivityType: String {
|
||||||
|
|
||||||
extension UserActivityType {
|
extension UserActivityType {
|
||||||
@MainActor
|
@MainActor
|
||||||
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
|
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
|
||||||
switch self {
|
switch self {
|
||||||
case .mainScene:
|
case .mainScene:
|
||||||
fatalError("cannot handle main scene activity")
|
fatalError("cannot handle main scene activity")
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
//
|
||||||
|
// AccountDisplayAndUserNameLabel.swift
|
||||||
|
// Tusker
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 7/20/24.
|
||||||
|
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import Pachyderm
|
||||||
|
|
||||||
|
class AccountDisplayAndUserNameLabel: EmojiLabel {
|
||||||
|
var baseFont: UIFontDescriptor = .preferredFontDescriptor(withTextStyle: .body)
|
||||||
|
|
||||||
|
private var state: State?
|
||||||
|
|
||||||
|
func updateUI(account: some AccountProtocol) {
|
||||||
|
let state = State(accountID: account.id, displayName: account.displayName, acct: account.acct)
|
||||||
|
guard state != self.state || Preferences.shared.hideCustomEmojiInUsernames != hasEmojis else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.state = state
|
||||||
|
self.attributedText = makeAttributedText(state: state)
|
||||||
|
if Preferences.shared.hideCustomEmojiInUsernames {
|
||||||
|
self.removeEmojis()
|
||||||
|
} else {
|
||||||
|
self.setEmojis(account.emojis, identifier: state.accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAttributedText(state: State) -> NSAttributedString {
|
||||||
|
let s = NSMutableAttributedString()
|
||||||
|
s.append(NSAttributedString(string: state.displayName, attributes: [
|
||||||
|
.font: UIFont(descriptor: baseFont.addingAttributes([
|
||||||
|
.traits: [
|
||||||
|
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
|
||||||
|
]
|
||||||
|
]), size: 0),
|
||||||
|
]))
|
||||||
|
s.append(NSAttributedString(string: " "))
|
||||||
|
s.append(NSAttributedString(string: "@\(state.acct)", attributes: [
|
||||||
|
.font: UIFont(descriptor: baseFont.addingAttributes([
|
||||||
|
.traits: [
|
||||||
|
UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue,
|
||||||
|
]
|
||||||
|
]), size: 0),
|
||||||
|
.foregroundColor: UIColor.secondaryLabel,
|
||||||
|
]))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct State: Equatable {
|
||||||
|
var accountID: String
|
||||||
|
var displayName: String
|
||||||
|
var acct: String
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ class AccountDisplayNameLabel: EmojiLabel {
|
||||||
private var accountDisplayName: String?
|
private var accountDisplayName: String?
|
||||||
|
|
||||||
func updateForAccountDisplayName(account: some AccountProtocol) {
|
func updateForAccountDisplayName(account: some AccountProtocol) {
|
||||||
guard accountID != account.id || accountDisplayName != account.displayName || Preferences.shared.hideCustomEmojiInUsernames == hasEmojis else {
|
guard accountID != account.id || accountDisplayName != account.displayName || Preferences.shared.hideCustomEmojiInUsernames != hasEmojis else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
accountID = account.id
|
accountID = account.id
|
||||||
|
|
|
@ -380,6 +380,8 @@ class AttachmentView: GIFImageView {
|
||||||
self.badgeContainer = stack
|
self.badgeContainer = stack
|
||||||
stack.axis = .horizontal
|
stack.axis = .horizontal
|
||||||
stack.spacing = 2
|
stack.spacing = 2
|
||||||
|
// badges should appear on top of any subsequently added views (e.g., gifv)
|
||||||
|
stack.layer.zPosition = 100
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .bold))
|
let font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: .systemFont(ofSize: 13, weight: .bold))
|
||||||
|
@ -511,7 +513,7 @@ extension AttachmentView: UIContextMenuInteractionDelegate {
|
||||||
} else if self.attachment.kind == .gifv || self.attachment.kind == .video {
|
} else if self.attachment.kind == .gifv || self.attachment.kind == .video {
|
||||||
itemSource = VideoActivityItemSource(asset: AVAsset(url: self.attachment.url), url: self.attachment.url)
|
itemSource = VideoActivityItemSource(asset: AVAsset(url: self.attachment.url), url: self.attachment.url)
|
||||||
itemData = Task {
|
itemData = Task {
|
||||||
try? await URLSession.shared.data(from: self.attachment.url).0
|
try? await URLSession.appDefault.data(from: self.attachment.url).0
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
|
|
|
@ -17,6 +17,7 @@ class GifvController {
|
||||||
let player: AVPlayer
|
let player: AVPlayer
|
||||||
|
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
|
private var audioSessionToken: AudioSessionCoordinator.Token?
|
||||||
|
|
||||||
let presentationSizeSubject = PassthroughSubject<CGSize, Never>()
|
let presentationSizeSubject = PassthroughSubject<CGSize, Never>()
|
||||||
private var presentationSizeObservation: NSKeyValueObservation?
|
private var presentationSizeObservation: NSKeyValueObservation?
|
||||||
|
@ -29,7 +30,9 @@ class GifvController {
|
||||||
self.isGrayscale = Preferences.shared.grayscaleImages
|
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||||
|
|
||||||
player.isMuted = true
|
player.isMuted = true
|
||||||
#if !os(visionOS)
|
#if os(visionOS)
|
||||||
|
player.preventsAutomaticBackgroundingDuringVideoPlayback = false
|
||||||
|
#else
|
||||||
player.preventsDisplaySleepDuringVideoPlayback = false
|
player.preventsDisplaySleepDuringVideoPlayback = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -41,12 +44,19 @@ class GifvController {
|
||||||
|
|
||||||
func play() {
|
func play() {
|
||||||
if player.rate == 0 {
|
if player.rate == 0 {
|
||||||
player.play()
|
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .gifv) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
player.pause()
|
player.pause()
|
||||||
|
if let audioSessionToken {
|
||||||
|
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePresentationSizeObservation() {
|
private func updatePresentationSizeObservation() {
|
||||||
|
|
|
@ -22,6 +22,7 @@ class GifvPlayerView: UIView {
|
||||||
|
|
||||||
let controller: GifvController
|
let controller: GifvController
|
||||||
private var presentationSizeCancellable: AnyCancellable?
|
private var presentationSizeCancellable: AnyCancellable?
|
||||||
|
private var wasPlayingWhenSceneBackgrounded = false
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
controller.item.presentationSize
|
controller.item.presentationSize
|
||||||
|
@ -45,4 +46,30 @@ class GifvPlayerView: UIView {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func willMove(toWindow newWindow: UIWindow?) {
|
||||||
|
super.willMove(toWindow: newWindow)
|
||||||
|
|
||||||
|
if let oldWindow = window,
|
||||||
|
let oldScene = oldWindow.windowScene {
|
||||||
|
NotificationCenter.default.removeObserver(self, name: UIScene.didEnterBackgroundNotification, object: oldScene)
|
||||||
|
NotificationCenter.default.removeObserver(self, name: UIScene.willEnterForegroundNotification, object: oldScene)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let newWindow,
|
||||||
|
let newScene = newWindow.windowScene {
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(sceneDidEnterBackground), name: UIScene.didEnterBackgroundNotification, object: newScene)
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: newScene)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func sceneDidEnterBackground() {
|
||||||
|
wasPlayingWhenSceneBackgrounded = controller.player.rate > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func sceneWillEnterForeground() {
|
||||||
|
if wasPlayingWhenSceneBackgrounded {
|
||||||
|
controller.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,11 @@ class CachedImageView: UIImageView {
|
||||||
commonInit()
|
commonInit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
fetchTask?.cancel()
|
||||||
|
blurHashTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
private func commonInit() {
|
private func commonInit() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ class ConfirmReblogStatusPreviewView: UIView {
|
||||||
private static let htmlConverter = HTMLConverter(
|
private static let htmlConverter = HTMLConverter(
|
||||||
font: .preferredFont(forTextStyle: .caption2),
|
font: .preferredFont(forTextStyle: .caption2),
|
||||||
monospaceFont: UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
monospaceFont: UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
||||||
|
fontMetrics: UIFontMetrics(forTextStyle: .caption2),
|
||||||
color: .label,
|
color: .label,
|
||||||
paragraphStyle: .default
|
paragraphStyle: .default
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,6 +25,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
private static let defaultBodyHTMLConverter = HTMLConverter(
|
private static let defaultBodyHTMLConverter = HTMLConverter(
|
||||||
font: .preferredFont(forTextStyle: .body),
|
font: .preferredFont(forTextStyle: .body),
|
||||||
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
||||||
|
fontMetrics: .default,
|
||||||
color: .label,
|
color: .label,
|
||||||
paragraphStyle: .default
|
paragraphStyle: .default
|
||||||
)
|
)
|
||||||
|
@ -36,6 +37,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
|
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
|
||||||
var emojiTextColor: UIColor = .label
|
var emojiTextColor: UIColor = .label
|
||||||
|
|
||||||
|
private let tapRecognizer = UITapGestureRecognizer()
|
||||||
|
|
||||||
// The link range currently being previewed
|
// The link range currently being previewed
|
||||||
private var currentPreviewedLinkRange: NSRange?
|
private var currentPreviewedLinkRange: NSRange?
|
||||||
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
|
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
|
||||||
|
@ -78,8 +81,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
updateLinkUnderlineStyle()
|
updateLinkUnderlineStyle()
|
||||||
|
|
||||||
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
|
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
|
||||||
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
|
tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
|
||||||
addGestureRecognizer(recognizer)
|
tapRecognizer.delegate = self
|
||||||
|
addGestureRecognizer(tapRecognizer)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
|
||||||
underlineTextLinksCancellable =
|
underlineTextLinksCancellable =
|
||||||
|
@ -132,12 +136,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
|
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
|
||||||
// if there currently is a selection, deselct it on single-tap
|
|
||||||
if selectedRange.length > 0 {
|
|
||||||
// location doesn't matter since we are non-editable and the cursor isn't visible
|
|
||||||
selectedRange = NSRange(location: 0, length: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let location = recognizer.location(in: self)
|
let location = recognizer.location(in: self)
|
||||||
if let (link, range) = getLinkAtPoint(location),
|
if let (link, range) = getLinkAtPoint(location),
|
||||||
link.scheme != dataDetectorsScheme {
|
link.scheme != dataDetectorsScheme {
|
||||||
|
@ -384,3 +382,25 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ContentTextView: UIGestureRecognizerDelegate {
|
||||||
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
// NB: This method is both a gesture recognizer delegate method and a UIView method.
|
||||||
|
// We only want to prevent our own tap gesture recognizer from beginning, but don't
|
||||||
|
// want to interfere with any other gestures that may begin over this view.
|
||||||
|
if gestureRecognizer === tapRecognizer {
|
||||||
|
let location = gestureRecognizer.location(in: self)
|
||||||
|
if let (link, _) = getLinkAtPoint(location) {
|
||||||
|
if link.scheme == dataDetectorsScheme {
|
||||||
|
return false
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ class PollOptionView: UIView {
|
||||||
private static let minHeight: CGFloat = 35
|
private static let minHeight: CGFloat = 35
|
||||||
private static let cornerRadius = 0.1 * minHeight
|
private static let cornerRadius = 0.1 * minHeight
|
||||||
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
|
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
|
||||||
|
private static let hoveredBackgroundColor = UIColor(white: 0.35, alpha: 0.25)
|
||||||
|
|
||||||
private(set) var label: EmojiLabel!
|
private(set) var label: EmojiLabel!
|
||||||
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
|
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
|
||||||
|
@ -33,6 +34,12 @@ class PollOptionView: UIView {
|
||||||
private var labelLeadingToSelfConstraint: NSLayoutConstraint!
|
private var labelLeadingToSelfConstraint: NSLayoutConstraint!
|
||||||
private var fillViewWidthConstraint: NSLayoutConstraint?
|
private var fillViewWidthConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
|
var hovered: Bool = false {
|
||||||
|
didSet {
|
||||||
|
backgroundColor = hovered ? PollOptionView.hoveredBackgroundColor : PollOptionView.unselectedBackgroundColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
|
|
@ -24,12 +24,19 @@ class PollOptionsView: UIControl {
|
||||||
private var poll: Poll!
|
private var poll: Poll!
|
||||||
private var animator: UIViewPropertyAnimator!
|
private var animator: UIViewPropertyAnimator!
|
||||||
private var currentSelectedOptionIndex: Int?
|
private var currentSelectedOptionIndex: Int?
|
||||||
|
private var currentHoveredOptionIndex: Int?
|
||||||
|
|
||||||
private let animationDuration: TimeInterval = 0.1
|
static let animationDuration: TimeInterval = 0.1
|
||||||
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
static let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
private let generator = UISelectionFeedbackGenerator()
|
private lazy var generator: UISelectionFeedbackGenerator = {
|
||||||
|
if #available(iOS 17.5, *) {
|
||||||
|
UISelectionFeedbackGenerator(view: self)
|
||||||
|
} else {
|
||||||
|
UISelectionFeedbackGenerator()
|
||||||
|
}
|
||||||
|
}()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
override var isEnabled: Bool {
|
override var isEnabled: Bool {
|
||||||
|
@ -59,6 +66,8 @@ class PollOptionsView: UIControl {
|
||||||
stack.topAnchor.constraint(equalTo: topAnchor),
|
stack.topAnchor.constraint(equalTo: topAnchor),
|
||||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
|
@ -121,6 +130,20 @@ class PollOptionsView: UIControl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func optionView(at point: CGPoint) -> (PollOptionView, Int)? {
|
||||||
|
for (index, view) in options.enumerated() {
|
||||||
|
// don't use view.frame because it changes when a transform is applied
|
||||||
|
var frame = CGRect(x: 0, y: view.center.y - view.bounds.height / 2, width: view.bounds.width, height: view.bounds.height)
|
||||||
|
if index != options.count - 1 {
|
||||||
|
frame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: -stack.spacing, right: 0))
|
||||||
|
}
|
||||||
|
if frame.contains(point) {
|
||||||
|
return (view, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UIControl
|
// MARK: - UIControl
|
||||||
|
|
||||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||||
|
@ -132,13 +155,21 @@ class PollOptionsView: UIControl {
|
||||||
if view.point(inside: touch.location(in: view), with: event) {
|
if view.point(inside: touch.location(in: view), with: event) {
|
||||||
currentSelectedOptionIndex = index
|
currentSelectedOptionIndex = index
|
||||||
|
|
||||||
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
|
if animator?.isRunning == true {
|
||||||
view.transform = self.scaledTransform
|
animator.stopAnimation(true)
|
||||||
|
}
|
||||||
|
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
|
||||||
|
view.transform = Self.scaledTransform
|
||||||
|
view.hovered = true
|
||||||
}
|
}
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
generator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
generator.selectionChanged(at: view.center)
|
||||||
|
} else {
|
||||||
|
generator.selectionChanged()
|
||||||
|
}
|
||||||
generator.prepare()
|
generator.prepare()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -151,30 +182,31 @@ class PollOptionsView: UIControl {
|
||||||
|
|
||||||
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||||
let location = touch.location(in: self)
|
let location = touch.location(in: self)
|
||||||
var newIndex: Int? = nil
|
let newIndexAndOption = optionView(at: location)
|
||||||
for (index, view) in options.enumerated() {
|
let newIndex = newIndexAndOption?.1
|
||||||
var frame = view.frame
|
let option = newIndexAndOption?.0
|
||||||
if index != options.count - 1 {
|
|
||||||
frame = frame.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: -stack.spacing, right: 0))
|
|
||||||
}
|
|
||||||
if frame.contains(location) {
|
|
||||||
newIndex = index
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if newIndex != currentSelectedOptionIndex {
|
if newIndex != currentSelectedOptionIndex {
|
||||||
currentSelectedOptionIndex = newIndex
|
currentSelectedOptionIndex = newIndex
|
||||||
|
|
||||||
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) {
|
if animator.isRunning {
|
||||||
|
animator.stopAnimation(true)
|
||||||
|
}
|
||||||
|
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
|
||||||
for (index, view) in self.options.enumerated() {
|
for (index, view) in self.options.enumerated() {
|
||||||
view.transform = index == newIndex ? self.scaledTransform : .identity
|
view.transform = index == newIndex ? Self.scaledTransform : .identity
|
||||||
|
view.hovered = index == newIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
if newIndex != nil {
|
if let option {
|
||||||
generator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
generator.selectionChanged(at: option.center)
|
||||||
|
} else {
|
||||||
|
generator.selectionChanged()
|
||||||
|
}
|
||||||
generator.prepare()
|
generator.prepare()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
@ -189,14 +221,15 @@ class PollOptionsView: UIControl {
|
||||||
func selectOption() {
|
func selectOption() {
|
||||||
guard let index = currentSelectedOptionIndex else { return }
|
guard let index = currentSelectedOptionIndex else { return }
|
||||||
let option = options[index]
|
let option = options[index]
|
||||||
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
|
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
|
||||||
option.transform = .identity
|
option.transform = .identity
|
||||||
|
option.hovered = false
|
||||||
self.selectOption(option)
|
self.selectOption(option)
|
||||||
}
|
}
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
}
|
}
|
||||||
|
|
||||||
if animator.isRunning {
|
if animator?.isRunning == true {
|
||||||
animator.addCompletion { (_) in
|
animator.addCompletion { (_) in
|
||||||
selectOption()
|
selectOption()
|
||||||
}
|
}
|
||||||
|
@ -207,12 +240,52 @@ class PollOptionsView: UIControl {
|
||||||
|
|
||||||
override func cancelTracking(with event: UIEvent?) {
|
override func cancelTracking(with event: UIEvent?) {
|
||||||
super.cancelTracking(with: event)
|
super.cancelTracking(with: event)
|
||||||
UIView.animate(withDuration: animationDuration, delay: 0, options: .curveEaseInOut) {
|
if animator?.isRunning == true {
|
||||||
|
animator.stopAnimation(true)
|
||||||
|
}
|
||||||
|
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
|
||||||
for view in self.options {
|
for view in self.options {
|
||||||
view.transform = .identity
|
view.transform = .identity
|
||||||
|
view.hovered = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func hoverRecognized(_ recognizer: UIHoverGestureRecognizer) {
|
||||||
|
guard let (option, index) = optionView(at: recognizer.location(in: self)) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began, .changed:
|
||||||
|
if index != currentHoveredOptionIndex {
|
||||||
|
let oldIndex = currentHoveredOptionIndex
|
||||||
|
currentHoveredOptionIndex = index
|
||||||
|
if animator?.isRunning == true {
|
||||||
|
animator.stopAnimation(true)
|
||||||
|
}
|
||||||
|
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
|
||||||
|
option.hovered = true
|
||||||
|
if let oldIndex {
|
||||||
|
self.options[oldIndex].hovered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
case .ended, .cancelled:
|
||||||
|
if let currentHoveredOptionIndex {
|
||||||
|
self.currentHoveredOptionIndex = nil
|
||||||
|
if animator?.isRunning == true {
|
||||||
|
animator.stopAnimation(true)
|
||||||
|
}
|
||||||
|
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
|
||||||
|
self.options[currentHoveredOptionIndex].hovered = false
|
||||||
|
}
|
||||||
|
animator.startAnimation()
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,7 @@ class StatusPollView: UIView, StatusContentView {
|
||||||
addSubview(infoLabel)
|
addSubview(infoLabel)
|
||||||
|
|
||||||
voteButton = UIButton(configuration: .plain())
|
voteButton = UIButton(configuration: .plain())
|
||||||
|
voteButton.isPointerInteractionEnabled = true
|
||||||
voteButton.translatesAutoresizingMaskIntoConstraints = false
|
voteButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
|
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
|
||||||
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||||
|
|
|
@ -12,20 +12,24 @@ 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),
|
||||||
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
|
||||||
|
fontMetrics: .default,
|
||||||
color: .label,
|
color: .label,
|
||||||
paragraphStyle: .default
|
paragraphStyle: .default
|
||||||
)
|
)
|
||||||
|
|
||||||
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 label = EmojiLabel()
|
private let textView = ContentTextView()
|
||||||
private var iconView: UIView?
|
private var iconView: UIView?
|
||||||
|
|
||||||
private var currentTargetedPreview: UITargetedPreview?
|
private var currentTargetedPreview: UITargetedPreview?
|
||||||
|
@ -38,34 +42,28 @@ class ProfileFieldValueView: UIView {
|
||||||
|
|
||||||
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
|
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
|
||||||
|
|
||||||
var range = NSRange(location: 0, length: 0)
|
#if os(visionOS)
|
||||||
if converted.length != 0,
|
textView.linkTextAttributes = [
|
||||||
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
|
.foregroundColor: UIColor.link
|
||||||
link = (converted.attributedSubstring(from: range).string, url)
|
]
|
||||||
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
|
#else
|
||||||
label.addInteraction(UIContextMenuInteraction(delegate: self))
|
textView.linkTextAttributes = [
|
||||||
label.isUserInteractionEnabled = true
|
.foregroundColor: UIColor.tintColor
|
||||||
|
]
|
||||||
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
|
#endif
|
||||||
guard value != nil else { return }
|
textView.backgroundColor = nil
|
||||||
#if os(visionOS)
|
textView.isScrollEnabled = false
|
||||||
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
|
textView.isSelectable = false
|
||||||
#else
|
textView.isEditable = false
|
||||||
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
|
textView.font = .preferredFont(forTextStyle: .body)
|
||||||
#endif
|
updateTextContainerInset()
|
||||||
// the .link attribute in a UILabel always makes the color blue >.>
|
textView.adjustsFontForContentSizeCategory = true
|
||||||
converted.removeAttribute(.link, range: range)
|
textView.attributedText = converted
|
||||||
}
|
textView.setEmojis(account.emojis, identifier: account.id)
|
||||||
}
|
textView.isUserInteractionEnabled = true
|
||||||
|
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
label.numberOfLines = 0
|
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
label.font = .preferredFont(forTextStyle: .body)
|
addSubview(textView)
|
||||||
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
|
||||||
|
|
||||||
|
@ -82,20 +80,20 @@ class ProfileFieldValueView: UIView {
|
||||||
icon.isPointerInteractionEnabled = true
|
icon.isPointerInteractionEnabled = true
|
||||||
icon.accessibilityLabel = "Verified link"
|
icon.accessibilityLabel = "Verified link"
|
||||||
addSubview(icon)
|
addSubview(icon)
|
||||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
|
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
|
||||||
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
|
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||||
}
|
}
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
label.leadingAnchor.constraint(equalTo: leadingAnchor),
|
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
labelTrailingConstraint,
|
labelTrailingConstraint,
|
||||||
label.topAnchor.constraint(equalTo: topAnchor),
|
textView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
label.bottomAnchor.constraint(equalTo: bottomAnchor),
|
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,37 +102,36 @@ class ProfileFieldValueView: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||||
var size = label.sizeThatFits(size)
|
var size = textView.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) {
|
||||||
label.textAlignment = alignment
|
textView.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() {
|
||||||
|
@ -144,7 +141,7 @@ class ProfileFieldValueView: UIView {
|
||||||
let view = ProfileFieldVerificationView(
|
let view = ProfileFieldVerificationView(
|
||||||
acct: account.acct,
|
acct: account.acct,
|
||||||
verifiedAt: field.verifiedAt!,
|
verifiedAt: field.verifiedAt!,
|
||||||
linkText: label.text ?? "",
|
linkText: textView.text ?? "",
|
||||||
navigationDelegate: navigationDelegate
|
navigationDelegate: navigationDelegate
|
||||||
)
|
)
|
||||||
let host = UIHostingController(rootView: view)
|
let host = UIHostingController(rootView: view)
|
||||||
|
@ -168,49 +165,3 @@ 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!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ class ProfileHeaderMovedOverlayView: UIView {
|
||||||
weak var delegate: TuskerNavigationDelegate?
|
weak var delegate: TuskerNavigationDelegate?
|
||||||
|
|
||||||
var collapse: (() -> Void)?
|
var collapse: (() -> Void)?
|
||||||
|
var hide: (() -> Void)?
|
||||||
|
|
||||||
private var avatarImageView: CachedImageView!
|
private var avatarImageView: CachedImageView!
|
||||||
private var displayNameLabel: EmojiLabel!
|
private var displayNameLabel: EmojiLabel!
|
||||||
|
@ -145,6 +146,45 @@ class ProfileHeaderMovedOverlayView: UIView {
|
||||||
delegate?.selected(account: movedToID)
|
delegate?.selected(account: movedToID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Accessibility
|
||||||
|
|
||||||
|
override var isAccessibilityElement: Bool {
|
||||||
|
get { true }
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityLabel: String? {
|
||||||
|
get {
|
||||||
|
guard let movedToID,
|
||||||
|
let account = delegate?.apiController?.persistentContainer.account(for: movedToID) else {
|
||||||
|
return "This account has moved"
|
||||||
|
}
|
||||||
|
return "This account has moved to @\(account.acct)"
|
||||||
|
}
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func accessibilityActivate() -> Bool {
|
||||||
|
guard let movedToID,
|
||||||
|
let delegate else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delegate.selected(account: movedToID)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
|
||||||
|
get {
|
||||||
|
[
|
||||||
|
UIAccessibilityCustomAction(name: "Hide banner", actionHandler: { [unowned self] _ in
|
||||||
|
self.hide?()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
set {}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProfileHeaderMovedOverlayView: UIPointerInteractionDelegate {
|
extension ProfileHeaderMovedOverlayView: UIPointerInteractionDelegate {
|
||||||
|
|
|
@ -25,9 +25,9 @@ class ProfileHeaderView: UIView {
|
||||||
weak var delegate: ProfileHeaderViewDelegate?
|
weak var delegate: ProfileHeaderViewDelegate?
|
||||||
var mastodonController: MastodonController! { delegate?.apiController }
|
var mastodonController: MastodonController! { delegate?.apiController }
|
||||||
|
|
||||||
@IBOutlet weak var headerImageView: UIImageView!
|
@IBOutlet weak var headerImageView: CachedImageView!
|
||||||
@IBOutlet weak var avatarContainerView: UIView!
|
@IBOutlet weak var avatarContainerView: UIView!
|
||||||
@IBOutlet weak var avatarImageView: UIImageView!
|
@IBOutlet weak var avatarImageView: CachedImageView!
|
||||||
@IBOutlet weak var moreButton: ProfileHeaderButton!
|
@IBOutlet weak var moreButton: ProfileHeaderButton!
|
||||||
@IBOutlet weak var followButton: ProfileHeaderButton!
|
@IBOutlet weak var followButton: ProfileHeaderButton!
|
||||||
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
|
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
|
||||||
|
@ -41,11 +41,10 @@ class ProfileHeaderView: UIView {
|
||||||
@IBOutlet weak var followersCountButton: UIButton!
|
@IBOutlet weak var followersCountButton: UIButton!
|
||||||
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
||||||
private var movedOverlayView: ProfileHeaderMovedOverlayView?
|
private var movedOverlayView: ProfileHeaderMovedOverlayView?
|
||||||
|
private var hideMovedOverlayView = false
|
||||||
|
|
||||||
var accountID: String!
|
var accountID: String!
|
||||||
|
|
||||||
private var imagesTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private var isGrayscale = false
|
private var isGrayscale = false
|
||||||
private var followButtonMode = FollowButtonMode.follow {
|
private var followButtonMode = FollowButtonMode.follow {
|
||||||
didSet {
|
didSet {
|
||||||
|
@ -56,10 +55,6 @@ class ProfileHeaderView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
imagesTask?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
|
@ -69,11 +64,13 @@ class ProfileHeaderView: UIView {
|
||||||
avatarContainerView.layer.cornerCurve = .continuous
|
avatarContainerView.layer.cornerCurve = .continuous
|
||||||
// Set zPositions so the gallery presentation/dismissal animation looks correct.
|
// Set zPositions so the gallery presentation/dismissal animation looks correct.
|
||||||
avatarContainerView.layer.zPosition = 2
|
avatarContainerView.layer.zPosition = 2
|
||||||
|
avatarImageView.cache = .avatars
|
||||||
avatarImageView.layer.masksToBounds = true
|
avatarImageView.layer.masksToBounds = true
|
||||||
avatarImageView.layer.cornerCurve = .continuous
|
avatarImageView.layer.cornerCurve = .continuous
|
||||||
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
|
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
|
||||||
avatarImageView.isUserInteractionEnabled = true
|
avatarImageView.isUserInteractionEnabled = true
|
||||||
|
|
||||||
|
headerImageView.cache = .headers
|
||||||
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
|
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
|
||||||
headerImageView.isUserInteractionEnabled = true
|
headerImageView.isUserInteractionEnabled = true
|
||||||
headerImageView.layer.zPosition = 1
|
headerImageView.layer.zPosition = 1
|
||||||
|
@ -138,11 +135,11 @@ class ProfileHeaderView: UIView {
|
||||||
usernameLabel.text = "@\(account.acct)"
|
usernameLabel.text = "@\(account.acct)"
|
||||||
lockImageView.isHidden = !account.locked
|
lockImageView.isHidden = !account.locked
|
||||||
|
|
||||||
imagesTask?.cancel()
|
if let avatar = account.avatar {
|
||||||
let avatar = account.avatar
|
avatarImageView.update(for: avatar)
|
||||||
let header = account.header
|
}
|
||||||
imagesTask = Task {
|
if let header = account.header {
|
||||||
await updateImages(avatar: avatar, header: header)
|
headerImageView.update(for: header)
|
||||||
}
|
}
|
||||||
|
|
||||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
|
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
|
||||||
|
@ -182,7 +179,8 @@ class ProfileHeaderView: UIView {
|
||||||
followersCountButton.setAttributedTitle(followersCountTitle, for: .normal)
|
followersCountButton.setAttributedTitle(followersCountTitle, for: .normal)
|
||||||
followersCountButton.accessibilityLabel = "\(followersSpelledOut) followers"
|
followersCountButton.accessibilityLabel = "\(followersSpelledOut) followers"
|
||||||
|
|
||||||
if let movedTo = account.movedTo {
|
if let movedTo = account.movedTo,
|
||||||
|
!hideMovedOverlayView {
|
||||||
if let movedOverlayView {
|
if let movedOverlayView {
|
||||||
movedOverlayView.updateUI(movedTo: movedTo)
|
movedOverlayView.updateUI(movedTo: movedTo)
|
||||||
} else {
|
} else {
|
||||||
|
@ -211,6 +209,7 @@ class ProfileHeaderView: UIView {
|
||||||
|
|
||||||
private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView {
|
private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView {
|
||||||
let overlay = ProfileHeaderMovedOverlayView()
|
let overlay = ProfileHeaderMovedOverlayView()
|
||||||
|
overlay.layer.zPosition = 1000
|
||||||
overlay.delegate = delegate
|
overlay.delegate = delegate
|
||||||
overlay.updateUI(movedTo: movedTo)
|
overlay.updateUI(movedTo: movedTo)
|
||||||
overlay.translatesAutoresizingMaskIntoConstraints = false
|
overlay.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
@ -238,6 +237,12 @@ class ProfileHeaderView: UIView {
|
||||||
}
|
}
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
}
|
}
|
||||||
|
overlay.hide = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.hideMovedOverlayView = true
|
||||||
|
self.updateUI(for: self.accountID)
|
||||||
|
UIAccessibility.post(notification: .layoutChanged, argument: self)
|
||||||
|
}
|
||||||
|
|
||||||
return overlay
|
return overlay
|
||||||
}
|
}
|
||||||
|
@ -294,44 +299,6 @@ class ProfileHeaderView: UIView {
|
||||||
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
|
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
|
||||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
|
|
||||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
|
||||||
isGrayscale = Preferences.shared.grayscaleImages
|
|
||||||
imagesTask?.cancel()
|
|
||||||
let avatar = account.avatar
|
|
||||||
let header = account.header
|
|
||||||
imagesTask = Task {
|
|
||||||
await updateImages(avatar: avatar, header: header)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private nonisolated func updateImages(avatar: URL?, header: URL?) async {
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
guard let avatar,
|
|
||||||
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1,
|
|
||||||
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image),
|
|
||||||
!Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
self.avatarImageView.image = transformedImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
guard let header,
|
|
||||||
let image = await ImageCache.avatars.get(header, loadOriginal: true).1,
|
|
||||||
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image),
|
|
||||||
!Task.isCancelled else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await MainActor.run {
|
|
||||||
self.headerImageView.image = transformedImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await group.waitForAll()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatBigNumber(_ value: Int) -> (String, String) {
|
private func formatBigNumber(_ value: Int) -> (String, String) {
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
|
||||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iN0-l3-epB" customClass="ProfileHeaderView" customModule="Tusker" customModuleProvider="target">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iN0-l3-epB" customClass="ProfileHeaderView" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="0.0" y="48" width="414" height="150"/>
|
<rect key="frame" x="0.0" y="48" width="414" height="150"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
|
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
|
||||||
|
@ -23,7 +23,7 @@
|
||||||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
|
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
|
||||||
<rect key="frame" x="16" y="138" width="120" height="120"/>
|
<rect key="frame" x="16" y="138" width="120" height="120"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
|
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||||
<rect key="frame" x="2" y="2" width="116" height="116"/>
|
<rect key="frame" x="2" y="2" width="116" height="116"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>
|
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>
|
||||||
|
@ -93,7 +93,7 @@
|
||||||
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="udp-EN-wtc">
|
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="udp-EN-wtc">
|
||||||
<rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
|
<rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="104" height="20.5"/>
|
<rect key="frame" x="0.0" y="0.0" width="104" height="20.5"/>
|
||||||
<state key="normal" title="Button"/>
|
<state key="normal" title="Button"/>
|
||||||
<buttonConfiguration key="configuration" style="plain" title="123 Following">
|
<buttonConfiguration key="configuration" style="plain" title="123 Following">
|
||||||
|
@ -104,7 +104,7 @@
|
||||||
<action selector="followingCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
|
<action selector="followingCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
|
||||||
</connections>
|
</connections>
|
||||||
</button>
|
</button>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" id="XCX-Y3-cG5">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" id="XCX-Y3-cG5">
|
||||||
<rect key="frame" x="112" y="0.0" width="106.5" height="20.5"/>
|
<rect key="frame" x="112" y="0.0" width="106.5" height="20.5"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<state key="normal" title="Button"/>
|
<state key="normal" title="Button"/>
|
||||||
|
|
|
@ -24,7 +24,13 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
|
||||||
private var changeSelectionPanRecognizer: UIGestureRecognizer!
|
private var changeSelectionPanRecognizer: UIGestureRecognizer!
|
||||||
private var selectedOptionAtStartOfPan: Value?
|
private var selectedOptionAtStartOfPan: Value?
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
|
private lazy var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator = {
|
||||||
|
if #available(iOS 17.5, *) {
|
||||||
|
UISelectionFeedbackGenerator(view: self)
|
||||||
|
} else {
|
||||||
|
UISelectionFeedbackGenerator()
|
||||||
|
}
|
||||||
|
}()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize {
|
override var intrinsicContentSize: CGSize {
|
||||||
|
@ -111,13 +117,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
|
||||||
|
|
||||||
func setSelectedOption(_ value: Value, animated: Bool) {
|
func setSelectedOption(_ value: Value, animated: Bool) {
|
||||||
guard selectedOption != value,
|
guard selectedOption != value,
|
||||||
options.contains(where: { $0.value == value }) else {
|
let index = options.firstIndex(where: { $0.value == value }) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
if selectedOption != nil {
|
if selectedOption != nil {
|
||||||
selectionChangedFeedbackGenerator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
let optionView = optionsStack.arrangedSubviews[index]
|
||||||
|
let location = convert(CGPoint(x: optionView.bounds.midX, y: optionView.bounds.midY), from: optionView)
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged(at: location)
|
||||||
|
} else {
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
@ -158,15 +170,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
|
||||||
// MARK: Interaction
|
// MARK: Interaction
|
||||||
|
|
||||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||||
|
guard gestureRecognizer === self.panGestureRecognizer else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
let beganOnSelectedOption: Bool
|
let beganOnSelectedOption: Bool
|
||||||
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
|
if let selectedIndex = options.firstIndex(where: { $0.value == selectedOption }),
|
||||||
optionsStack.arrangedSubviews[selectedIndex].frame.contains(self.panGestureRecognizer.location(in: optionsStack)) {
|
optionsStack.arrangedSubviews[selectedIndex].frame.contains(gestureRecognizer.location(in: optionsStack)) {
|
||||||
beganOnSelectedOption = true
|
beganOnSelectedOption = true
|
||||||
} else {
|
} else {
|
||||||
beganOnSelectedOption = false
|
beganOnSelectedOption = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// only begin changing selection if the gesutre started on the currently selected item
|
// only begin changing selection if the gesture started on the currently selected item
|
||||||
// otherwise, let the scroll view handle things
|
// otherwise, let the scroll view handle things
|
||||||
if gestureRecognizer == self.changeSelectionPanRecognizer {
|
if gestureRecognizer == self.changeSelectionPanRecognizer {
|
||||||
return beganOnSelectedOption
|
return beganOnSelectedOption
|
||||||
|
@ -223,7 +239,12 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
|
||||||
}
|
}
|
||||||
animator.startAnimation()
|
animator.startAnimation()
|
||||||
#if !os(visionOS)
|
#if !os(visionOS)
|
||||||
selectionChangedFeedbackGenerator.selectionChanged()
|
if #available(iOS 17.5, *) {
|
||||||
|
let locationInSelf = convert(location, from: optionsStack)
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged(at: locationInSelf)
|
||||||
|
} else {
|
||||||
|
selectionChangedFeedbackGenerator.selectionChanged()
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -20,6 +20,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
private static let htmlConverter = HTMLConverter(
|
private static let htmlConverter = HTMLConverter(
|
||||||
font: ConversationMainStatusCollectionViewCell.contentFont,
|
font: ConversationMainStatusCollectionViewCell.contentFont,
|
||||||
monospaceFont: ConversationMainStatusCollectionViewCell.monospaceFont,
|
monospaceFont: ConversationMainStatusCollectionViewCell.monospaceFont,
|
||||||
|
fontMetrics: .default,
|
||||||
color: .label,
|
color: .label,
|
||||||
paragraphStyle: ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
paragraphStyle: ConversationMainStatusCollectionViewCell.contentParagraphStyle
|
||||||
)
|
)
|
||||||
|
@ -514,8 +515,15 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
|
||||||
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
|
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateAccountUI(account: AccountMO) {
|
||||||
|
baseUpdateAccountUI(account: account)
|
||||||
|
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||||
|
usernameLabel.text = "@\(account.acct)"
|
||||||
|
}
|
||||||
|
|
||||||
func updateUIForPreferences(status: StatusMO) {
|
func updateUIForPreferences(status: StatusMO) {
|
||||||
baseUpdateUIForPreferences(status: status)
|
baseUpdateUIForPreferences(status: status)
|
||||||
|
displayNameLabel.updateForAccountDisplayName(account: status.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func preferencesChanged() {
|
@objc private func preferencesChanged() {
|
||||||
|
@ -640,7 +648,7 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
||||||
return defaultRegion
|
return defaultRegion
|
||||||
} else if let button = interaction.view as? UIButton,
|
} else if let button = interaction.view as? UIButton,
|
||||||
actionButtons.contains(button) {
|
actionButtons.contains(button) {
|
||||||
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
|
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
|
||||||
rect = rect.insetBy(dx: -24, dy: -24)
|
rect = rect.insetBy(dx: -24, dy: -24)
|
||||||
return UIPointerRegion(rect: rect)
|
return UIPointerRegion(rect: rect)
|
||||||
}
|
}
|
||||||
|
@ -654,8 +662,8 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
||||||
} else if let button = interaction.view as? UIButton,
|
} else if let button = interaction.view as? UIButton,
|
||||||
actionButtons.contains(button) {
|
actionButtons.contains(button) {
|
||||||
let preview = UITargetedPreview(view: button.imageView!)
|
let preview = UITargetedPreview(view: button.imageView!)
|
||||||
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
|
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
|
||||||
rect = rect.insetBy(dx: -24, dy: -24)
|
rect = rect.insetBy(dx: -8, dy: -8)
|
||||||
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
|
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue