Compare commits

..

86 Commits

Author SHA1 Message Date
Shadowfacts 18f6445a7c Bump build number and update changelog 2024-07-30 22:28:44 -07:00
Shadowfacts c5f42719a0 Fix Cmd+3 not properly selecting Explore tab
Having MainSidebarViewController.Item.explore and .tab(.explore) was a
mistake and made it easy to accidentally use the wrong one for the key
command, so use .tab(.explore) for everything.

Closes #519
2024-07-30 22:03:36 -07:00
Shadowfacts eb89aec00f Bump build number and update changelog 2024-07-24 21:09:42 -07:00
Shadowfacts 61576bce58 Fix Drafts button never turning into Post on Mac Catalyst
Closes #504
2024-07-24 20:57:23 -07:00
Shadowfacts f7d4737782 Add more details to notification loading crash 2024-07-24 20:48:01 -07:00
Shadowfacts 3dd0f3a154 Report DraftsPersistentContainer initializer errors to Sentry 2024-07-24 20:42:35 -07:00
Shadowfacts 145ffbfcf0 Fix crash when selection changes to nil in custom alert
Closes #517
2024-07-24 20:33:17 -07:00
Shadowfacts bcf2a2f026 Improve compose reply view avatar scrolling animation 2024-07-24 20:26:33 -07:00
Shadowfacts 1358152dec Fix discrepancy between SearchResultsViewController.Item == and hash 2024-07-22 22:19:31 -07:00
Shadowfacts 2e2279ba8c Bump build number and update changelog 2024-07-22 21:56:44 -07:00
Shadowfacts 60dadf599c Fix status meta indicators overlapping thread links
This isn't great, but there's nowhere else for either to go and the
difference between the large/default scales wasn't doing much before.

Closes #449
2024-07-22 21:48:43 -07:00
Shadowfacts 90537f9d12 Fix not being able to resolve remote Mastodon posts
Closes #515
2024-07-22 21:40:19 -07:00
Shadowfacts 8b0c2f80b6 Fix preserving conversation expand all not working for ancestors
Closes #516
2024-07-22 21:28:42 -07:00
Shadowfacts 42423f36db Fix Dynamic Type not applying to status content 2024-07-21 19:46:17 -07:00
Shadowfacts 176eb7c011 Undo overzealous Xcode rename 2024-07-21 18:41:03 -07:00
Shadowfacts da9ca78a8b Update card view less often
Speculative fix for #314
2024-07-21 18:40:58 -07:00
Shadowfacts b470ee6401 Fix status/mention push notifications not showing CW and fix sensitive attachments being included in push notifications
Closes #512
2024-07-21 18:13:48 -07:00
Shadowfacts fccd4e427c Fix profile moved header not being VO accessible
Closes #479
2024-07-21 14:03:18 -07:00
Shadowfacts f25031afd4 Fix profile moved view appearing behind avatar/header images 2024-07-21 13:46:07 -07:00
Shadowfacts ca65f84137 Lift pinned timelines modifiers up to CustomizeTimelinesList
.sheet on a Section with a header does not work, and produces warnings
about trying to present on a VC that already has a presentation. It
seems like the .sheet modifier is being applied to both the Section
content and header?

Fixes #514
2024-07-21 12:02:14 -07:00
Shadowfacts d4057adf4d Avoid updating AccountDisplayNameLabel when emojis pref hasn't changed
Oops
2024-07-21 10:36:24 -07:00
Shadowfacts 007937d2d7 Consolidate display/username labels on timeline statuses
Closes #513
2024-07-20 23:35:31 -07:00
Shadowfacts 5f040ed390 Workaround text view not being baseline-aligned with label
Closes #509
2024-07-20 11:08:59 -07:00
Shadowfacts 870d0c8404 Replay video from start when play is pressed at end
Closes #510
2024-07-20 10:33:08 -07:00
Shadowfacts 47b9ac890a Fix gallery controls visibility not transferring between pages
Closes #511
2024-07-20 10:27:49 -07:00
Shadowfacts 50b84350d9 Don't reload relationship every time profile page switched 2024-07-11 22:32:02 -07:00
Shadowfacts cdc64f1b2c Bump build number and update changelog 2024-07-10 20:49:50 -07:00
Shadowfacts 2913098e74 Fix badges not appearing on gifv attachments
Closes #507
2024-07-10 20:25:09 -07:00
Shadowfacts ce99352e90 Don't let let audio session tokens be double consumed 2024-07-09 23:27:18 -07:00
Shadowfacts 8322d3a36c Fix gifv playback not continuing when returning from background
Closes #506
2024-07-08 22:05:27 -07:00
Shadowfacts a818457f8c Fix gifv playing pausing audio from other apps
Closes #505
2024-07-08 22:05:27 -07:00
Shadowfacts 1f6644b703 Bump HTMLStreamer
Fixes crash when whitespace occurs at the end of <pre> content
2024-07-08 21:14:29 -07:00
Shadowfacts 412c5ee91d Fix multi column navigation not animating when scrolling back while replacing multiple columns 2024-07-07 10:01:33 -07:00
Shadowfacts dcc5f7f716 visionOS: Workaround for gallery not working at all 2024-07-06 17:36:45 -07:00
Shadowfacts 9fefc9e8f8 Precise video scrubbing for pointer/pencil 2024-07-06 17:26:33 -07:00
Shadowfacts d1af911241 Custom alert: show menu when long press moves onto menu button 2024-07-06 17:19:30 -07:00
Shadowfacts 5abd265195 Support haptic feedback on new Magic Keyboard 2024-07-06 17:10:29 -07:00
Shadowfacts 3cb0f46533 Add hover effects to poll view
Closes #503
2024-07-06 14:05:16 -07:00
Shadowfacts c367a2e9f1 Fix selecting poll option playing too much haptic feedback 2024-07-06 10:19:09 -07:00
Shadowfacts 3eceffbb6b Add visionOS app icon 2024-06-16 18:51:17 -07:00
Shadowfacts 7c3a00a40d Fix compiling for visionOS 2024-06-16 17:49:25 -07:00
Shadowfacts 45a90fb4a2 Fix compiling for Catalyst 2024-06-16 17:40:23 -07:00
Shadowfacts 8557e110a8 Bump build number and update changelog 2024-06-08 13:55:32 -07:00
Shadowfacts c2232a5e14 Don't fail decoding when one status fails to decode
Also remove old workaround for bad dates from #477

Closes #478
2024-06-08 13:29:56 -07:00
Shadowfacts e6d9a33dbf Actually don't purge old persistent history 2024-06-08 13:18:23 -07:00
Shadowfacts d8fccc8f1b Purge old persistent history after processing
Closes #480
2024-06-08 12:23:12 -07:00
Shadowfacts 6528070f1c Save persistent history tokens across launches
See #480
2024-06-08 12:18:22 -07:00
Shadowfacts 09c6a87e19 Fix switching sidebar sections with keyboard shortcuts not saving old section's navigation stack 2024-06-08 11:30:16 -07:00
Shadowfacts cd0d8fffcb Fix conversation thread links appearing above avatar when lifted by pointer 2024-06-08 11:28:19 -07:00
Shadowfacts 1b6f0c07fd Add pointer effect to search token suggestions 2024-06-08 11:26:10 -07:00
Shadowfacts 2f31b50a5b Fix search results always pushing new column in multi-column nav
Closes #498
2024-06-08 11:21:05 -07:00
Shadowfacts cee4e15b06 Fix not being able to select text by double clicking with cursor on iPad
Also fix not being able to single-tap data detector value to see menu

Closes #499
2024-06-08 11:01:07 -07:00
Shadowfacts 888f44366c Fix multi-column nav not animating scroll position when replacing subsequent columns
Closes #500
2024-06-08 10:32:32 -07:00
Shadowfacts c88076eec0 Use text view for profile field value view
Fixes #501
2024-06-08 10:23:24 -07:00
Shadowfacts afe47437e4 Disallow blocking your own domain 2024-06-02 11:41:50 -07:00
Shadowfacts 4dc484c3c2 Fix follow button never activating on Pixelfed
Caused by not being able to decode Relationship due to missing fields.
Also disable actions that are unsupported on Pixelfed.

Closes #481
2024-06-02 11:40:42 -07:00
Shadowfacts 0f2a85b108 Fix crash when opening push notification while VC modally presented
The dismissal of the modally presented VC turns the route change into an
asynchronous operation, even when not animated.

Closes #484
2024-06-02 11:25:49 -07:00
Shadowfacts 5e55ce75c2 Fix previous sidebar selection losing navigation stack in some circumstances 2024-06-02 10:33:25 -07:00
Shadowfacts eec2adbfd9 Set target content identifiers on scenes/activities 2024-06-02 10:10:16 -07:00
Shadowfacts a848f6e425 Fix error on Pixelfed/Firefish due to missing followers/following counts
Closes #483
2024-06-02 09:44:20 -07:00
Shadowfacts 44896d305e Add pointer interaction to profile followers/following buttons
Closes #497
2024-06-02 09:42:54 -07:00
Shadowfacts 6c70ed4b4e Fix crash in MultiColumnNavController due to closing already-removed VC
Not sure how this is possible, but there was a report of it

Closes #485
2024-06-02 09:41:22 -07:00
Shadowfacts e3c480131a Fix gallery dismiss transition from sheet-presented VC
Closes #490
2024-06-01 11:22:19 -07:00
Shadowfacts 575166f5b4 Fix Cmd+1/etc. resetting navigation stacks
Closes #491
2024-06-01 10:56:55 -07:00
Shadowfacts c60aa3e3f3 Fix close buttons unnecessarily being added to navigation column 2024-06-01 10:56:31 -07:00
Shadowfacts 75f0d12c82 Fix incorrect pointer actions on conversation main status
Closes #493
2024-06-01 10:47:56 -07:00
Shadowfacts 5cf2bc4fbf Fix profile header images being blurry
Due to the old method using ImageCache.avatars for the headers 🤦

Closes #494
2024-06-01 10:44:49 -07:00
Shadowfacts 908b499f8f Fix Remove Suggestion action missing from Suggested Accounts screen
Closes #495
2024-06-01 10:40:30 -07:00
Shadowfacts 67c7905acf Fix missing VC callbacks in removeViewAndController 2024-06-01 10:29:33 -07:00
Shadowfacts eacafe87b3 Fix logout from current resulting in black screen after switching to reused VC
Closes #489
2024-06-01 10:28:46 -07:00
Shadowfacts 2a53b24487 Merge branch 'public-beta' into develop 2024-05-29 22:42:43 -07:00
Shadowfacts 9df3c33c6c Bump build number and update changelog 2024-05-29 22:37:53 -07:00
Shadowfacts d4e82d6e7a Fix AVPlayer periodic time observers not being removed 2024-05-29 22:35:45 -07:00
Shadowfacts 06ba758309 Merge branch 'public-beta' into develop 2024-05-29 22:30:48 -07:00
Shadowfacts 2c56902389 Remove old account UI state when logging out 2024-05-29 22:23:09 -07:00
Shadowfacts cb3fd43dbd Fix video thubmnail being flipped in Compose
Closes #487
2024-05-29 22:03:53 -07:00
Shadowfacts 3d15759fb9 Don't constantly commit CA transactions when scrubbing video
Closes #488
2024-05-29 21:56:18 -07:00
Shadowfacts 5620b6ab78 Merge branch 'public-beta' into develop 2024-05-27 22:29:23 -07:00
Shadowfacts 09999175f7 Fix editing attachment descriptions not working on Pleroma 2024-05-27 22:29:11 -07:00
Shadowfacts f2a9f890ff Use development URLSession in more places 2024-05-27 22:14:28 -07:00
Shadowfacts 093994b474 More push subscription logging 2024-05-27 13:33:00 -07:00
Shadowfacts 3d0de5af04 Persist more state when switching accounts
Closes #486
2024-05-24 14:03:51 -04:00
Shadowfacts 966a906436 Fix AVPlayer periodic time observers not being removed 2024-05-23 14:29:56 -04:00
Shadowfacts 844d4056e3 Bump version and update changelog 2024-05-23 14:25:39 -04:00
Shadowfacts 00ef131bb6 Update HTMLStreamer 2024-05-23 14:12:35 -04:00
Shadowfacts d6be6f14dc Hide subscription section from tip jar when there are no products 2024-05-23 14:11:54 -04:00
106 changed files with 1637 additions and 697 deletions

View File

@ -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
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!

View File

@ -1,6 +1,79 @@
# 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:
- Add subscription option to Tip Jar

View File

@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
mutableContent.body = notification.body
mutableContent.userInfo["notificationID"] = notification.notificationID
mutableContent.userInfo["accountID"] = accountID
mutableContent.targetContentIdentifier = accountID
let task = Task {
await updateNotificationContent(mutableContent, account: account, push: notification)
@ -121,7 +122,12 @@ class NotificationService: UNNotificationServiceExtension {
let notificationContent: String?
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 {
notificationContent = nil
} else {
@ -134,7 +140,9 @@ class NotificationService: UNNotificationServiceExtension {
// 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.
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
attachmentDataTask = Task {
do {

View File

@ -24,6 +24,8 @@
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionServiceRoleType</key>
<string>NSExtensionServiceRoleTypeViewer</string>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>

View File

@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
case .video, .gifv:
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else

View File

@ -333,7 +333,12 @@ public final class ComposeController: ViewController {
}
.toolbar {
ToolbarItem(placement: .cancellationAction) { cancelButton }
#if targetEnvironment(macCatalyst)
ToolbarItem(placement: .topBarTrailing) { draftsButton }
ToolbarItem(placement: .confirmationAction) { postButton }
#else
ToolbarItem(placement: .confirmationAction) { postOrDraftsButton }
#endif
#if os(visionOS)
ToolbarItem(placement: .bottomOrnament) {
ControllerView(controller: { controller.toolbarController })
@ -461,20 +466,28 @@ public final class ComposeController: ViewController {
}
@ViewBuilder
private var postButton: some View {
private var postOrDraftsButton: some View {
if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
Button(action: controller.postStatus) {
Text(draft.editedStatusID == nil ? "Post" : "Edit")
}
.keyboardShortcut(.return, modifiers: .command)
.disabled(!controller.postButtonEnabled)
postButton
} else {
Button(action: controller.showDrafts) {
Text("Drafts")
}
draftsButton
}
}
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)
@available(iOS, obsoleted: 16.0)
private var keyboardInset: CGFloat {

View File

@ -16,6 +16,8 @@ public class DraftsPersistentContainer: NSPersistentContainer {
public static let shared = DraftsPersistentContainer()
public static var captureError: ((any Error) -> Void)?
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.module.url(forResource: "Drafts", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
@ -39,6 +41,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
loadPersistentStores { _, error in
if let error {
DraftsPersistentContainer.captureError?(error)
fatalError("Loading persistent store: \(error)")
}
}

View File

@ -39,6 +39,7 @@ extension TextViewCaretScrolling {
let animator = UIViewPropertyAnimator(duration: 0.2, curve: .easeInOut) {
scrollView.scrollRectToVisible(rectToMakeVisible, animated: false)
scrollView.layoutIfNeeded()
}
self.caretScrollPositionAnimator = animator
animator.startAnimation()

View File

@ -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
offset = min(offset, maxOffset)
return AvatarImageView(
url: status.account.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
.offset(x: 0, y: offset)
return AvatarContainerRepresentable(offset: offset) {
AvatarImageView(
url: status.account.avatar,
size: 50,
style: controller.config.avatarStyle,
fetchAvatar: controller.fetchAvatar
)
}
.frame(width: 50, height: 50)
.accessibilityHidden(true)
}
@ -94,3 +96,39 @@ private struct DisplayNameHeightPrefKey: PreferenceKey {
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")
}
}
}

View File

@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
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
container.addSubview(from.view)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
container.addSubview(to.view)
container.addSubview(from.view)
container.addSubview(content.view)
content.view.frame = destFrameInContainer

View File

@ -44,6 +44,7 @@ class GalleryItemViewController: UIViewController {
private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
}
@ -227,6 +228,8 @@ class GalleryItemViewController: UIViewController {
updateZoomScale(resetZoom: true)
}
centerContent()
// Ensure the transform is correct if the controls are hidden and their size changed.
setControlsVisible(controlsVisible, animated: false)
}
override func viewDidAppear(_ animated: Bool) {
@ -289,10 +292,12 @@ class GalleryItemViewController: UIViewController {
func setControlsVisible(_ visible: Bool, animated: Bool) {
controlsVisible = visible
guard let topControlsView,
let bottomControlsView else {
return
}
func updateControlsViews() {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)

View File

@ -125,6 +125,8 @@ extension GalleryViewController: UIPageViewControllerDataSource {
extension GalleryViewController: UIPageViewControllerDelegate {
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
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) {
@ -152,14 +154,21 @@ extension GalleryViewController: GalleryItemViewControllerDelegate {
extension GalleryViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
#if os(visionOS)
return nil
#else
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
return GalleryPresentationAnimationController(sourceView: sourceView)
} else {
return nil
}
#endif
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
#if os(visionOS)
return nil
#else
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
let translation: CGPoint?
let velocity: CGPoint?
@ -175,5 +184,6 @@ extension GalleryViewController: UIViewControllerTransitioningDelegate {
} else {
return nil
}
#endif
}
}

View File

@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
}
public var needsEditAttachmentsInSeparateRequest: Bool {
instanceType.isPleroma(.akkoma(nil))
instanceType.isPleroma
}
public var composeDirectStatuses: Bool {
@ -217,6 +217,18 @@ public final class InstanceFeatures: ObservableObject {
instanceType.isPleroma
}
public var muteNotifications: Bool {
!instanceType.isPixelfed
}
public var blockDomains: Bool {
!instanceType.isPixelfed
}
public var hideReblogs: Bool {
!instanceType.isPixelfed
}
public init() {
}
@ -338,6 +350,14 @@ extension InstanceFeatures {
return false
}
}
var isPixelfed: Bool {
if case .pixelfed = self {
return true
} else {
return false
}
}
}
@_spi(InstanceType) public enum MastodonType {

View File

@ -42,8 +42,7 @@ public struct Client: Sendable {
} else if let date = iso8601.date(from: str) {
return date
} else {
// throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
return Date(timeIntervalSinceReferenceDate: 0)
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
}
})
@ -205,8 +204,8 @@ public struct Client: Sendable {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
request.range = range
return request
}
@ -457,14 +456,13 @@ public struct Client: Sendable {
}
// 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)
}
// MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
request.range = range
return request
}
@ -492,7 +490,7 @@ public struct Client: Sendable {
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] = []
if let limit {
parameters.append("limit" => limit)

View File

@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
self.displayName = try container.decode(String.self, forKey: .displayName)
self.locked = try container.decode(Bool.self, forKey: .locked)
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
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.note = try container.decode(String.self, forKey: .note)
self.url = try container.decode(URL.self, forKey: .url)
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
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]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
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<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia,
"pinned" => pinned,
"exclude_replies" => excludeReplies,

View File

@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
self.blocking = try container.decode(Bool.self, forKey: .blocking)
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.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
// not supported on pixelfed
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
}

View File

@ -10,7 +10,7 @@ import Foundation
public struct SearchResults: Decodable, Sendable {
public let accounts: [Account]
public let statuses: [Status]
public let statuses: [TryDecode<Status>]
public let hashtags: [Hashtag]
private enum CodingKeys: String, CodingKey {

View File

@ -32,8 +32,8 @@ extension Timeline {
}
}
func request(range: RequestRange) -> Request<[Status]> {
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
if case .public(true) = self {
request.queryParameters.append("local" => true)
}

View File

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

View File

@ -104,6 +104,7 @@ class PushManagerImpl: _PushManager {
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
guard newEndpoint != $0.endpoint else {
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
return $0
}
var copy = $0

View File

@ -75,6 +75,7 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.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 */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.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 */; };
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.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 */; };
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.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 */; };
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.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 */; };
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.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 */; };
D6EBF01723C55E0D00AE061B /* UISceneSession+MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EBF01623C55E0D00AE061B /* UISceneSession+MastodonController.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 */; };
D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.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>"; };
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>"; };
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>"; };
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>"; };
@ -664,6 +669,7 @@
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>"; };
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>"; };
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>"; };
@ -685,6 +691,7 @@
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>"; };
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>"; };
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>"; };
@ -799,6 +806,7 @@
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>"; };
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>"; };
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>"; };
@ -1049,6 +1057,7 @@
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
);
path = CoreData;
sourceTree = "<group>";
@ -1473,7 +1482,9 @@
D6BED1722126661300F02DA0 /* Views */ = {
isa = PBXGroup;
children = (
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */,
D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameView.swift */,
D69F26332C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift */,
D68E6F5E253C9B2D001A1B4C /* BaseEmojiLabel.swift */,
D6ADB6EF28ED1F25009924AB /* CachedImageView.swift */,
D6895DC328D65342006341DA /* ConfirmReblogStatusPreviewView.swift */,
@ -1507,7 +1518,6 @@
D641C78B213DD92F004B4513 /* Profile Header */,
D641C78A213DD926004B4513 /* Status */,
D64AAE8F26C80DB600FC57FB /* Toast */,
D6D79F562A1160B800AB2315 /* AccountDisplayNameLabel.swift */,
);
path = Views;
sourceTree = "<group>";
@ -1602,6 +1612,7 @@
D691296D2BA75ACF005C58ED /* PrivacyInfo.xcprivacy */,
D671A6BE299DA96100A81FEA /* Tusker-Bridging-Header.h */,
D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */,
D6EEDE922C3CF21800E10E51 /* AudioSessionCoordinator.swift */,
D6D79F582A13293200AB2315 /* BackgroundManager.swift */,
D69261262BB3BA610023152C /* Box.swift */,
D61F75B6293C119700C0B37F /* Filterer.swift */,
@ -1757,6 +1768,7 @@
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
);
path = API;
sourceTree = "<group>";
@ -2141,6 +2153,7 @@
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
D6EEDE932C3CF21800E10E51 /* AudioSessionCoordinator.swift in Sources */,
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
@ -2347,6 +2360,7 @@
D65B4B542971F71D00DABDFB /* EditedReport.swift in Sources */,
D62D67C52A97D8CD00167EE2 /* MultiColumnNavigationController.swift in Sources */,
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */,
D69F26342C4CDFD300FAF761 /* AccountDisplayAndUserNameLabel.swift in Sources */,
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D68A76F129539116001DA1B3 /* FlipView.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
@ -2362,6 +2376,7 @@
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
@ -2408,6 +2423,7 @@
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
);
@ -2672,7 +2688,8 @@
isa = XCBuildConfiguration;
buildSettings = {
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;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
@ -2975,7 +2992,8 @@
isa = XCBuildConfiguration;
buildSettings = {
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;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
@ -3006,7 +3024,8 @@
isa = XCBuildConfiguration;
buildSettings = {
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;
CODE_SIGN_ENTITLEMENTS = Tusker/Tusker.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
@ -3241,7 +3260,7 @@
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
requirement = {
kind = exactVersion;
version = 0.2.3;
version = 0.3.0;
};
};
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {

View File

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

View File

@ -56,6 +56,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
// 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
#if canImport(Sentry)
DraftsPersistentContainer.captureError = { SentrySDK.capture(error: $0) }
#endif
_ = DraftsPersistentContainer.shared
DispatchQueue.global(qos: .userInitiated).async {
@ -185,7 +188,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let mastodonController = MastodonController.getForAccount(account)
do {
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
} catch {
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
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
rootViewController.select(route: .notifications, animated: false)
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false)
rootViewController.select(route: .notifications, animated: false) {
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false)
}
} else {
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()
}

View File

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

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,14 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"layers" : [
{
"filename" : "Front.solidimagestacklayer"
},
{
"filename" : "Back.solidimagestacklayer"
}
]
}

View File

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

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@ -96,7 +96,7 @@ final class ImageCache: @unchecked Sendable {
}
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
}
guard let image = UIImage(data: data) else {

View File

@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return context
}()
private var lastRemoteChangeToken: NSPersistentHistoryToken?
// 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
// 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.name = "View"
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
if accountInfo != nil {
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) {
@ -521,58 +521,82 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
}
@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
}
remoteChangesBackgroundContext.perform {
defer {
self.lastRemoteChangeToken = token
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
self.remoteChangesBackgroundContext.perform {
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 {
var changedHashtags = false
var changedInstances = false
var changedTimelinePositions = Set<NSManagedObjectID>()
var changedAccountPrefs = false
outer: for transaction in transactions {
for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" {
changedHashtags = true
} else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" {
changedTimelinePositions.insert(change.changedObjectID)
} else if change.changedObjectID.entity.name == "AccountPreferences" {
changedAccountPrefs = true
}
}
}
}
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
logger.info("Processing \(transactions.count) persistent history transactions")
var changedHashtags = false
var changedInstances = false
var changedTimelinePositions = Set<NSManagedObjectID>()
var changedAccountPrefs = false
outer: for transaction in transactions {
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" {
changedHashtags = true
} else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" {
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
let timelinePositions = changedTimelinePositions
let accountPrefs = changedAccountPrefs
DispatchQueue.main.async {
if hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
for id in timelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
// 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)
}
}
}
// Can't capture vars in concurrently-executing closure
let hashtags = changedHashtags
let instances = changedInstances
let timelinePositions = changedTimelinePositions
let accountPrefs = changedAccountPrefs
DispatchQueue.main.async {
if hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
for id in timelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
// 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)
}
}
}

View File

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

View File

@ -25,8 +25,14 @@ class HTMLConverter {
private let converter: AttributedStringConverter<Callbacks>
init(font: UIFont, monospaceFont: UIFont, color: UIColor, paragraphStyle: NSParagraphStyle) {
let config = AttributedStringConverterConfiguration(font: font, monospaceFont: monospaceFont, color: color, paragraphStyle: paragraphStyle)
init(font: UIFont, monospaceFont: UIFont, fontMetrics: UIFontMetrics, color: UIColor, paragraphStyle: NSParagraphStyle) {
let config = AttributedStringConverterConfiguration(
font: font,
monospaceFont: monospaceFont,
fontMetrics: fontMetrics,
color: color,
paragraphStyle: paragraphStyle
)
self.converter = AttributedStringConverter(configuration: config)
}

View File

@ -32,7 +32,7 @@ struct MenuController {
static let sidebarItemKeyCommands: [UIKeyCommand] = [
sidebarCommand(item: .tab(.timelines), command: "1", action: #selector(MainSplitViewController.handleSidebarCommandTimelines)),
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: .tab(.myProfile), command: "5", action: #selector(MainSplitViewController.handleSidebarCommandMyProfile)),
]

View File

@ -32,8 +32,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
}
launchActivity = activity
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
let account: UserAccountInfo
if let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {

View File

@ -29,6 +29,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
return
}
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
let account: UserAccountInfo
let controller: MastodonController
let draft: Draft?

View File

@ -83,7 +83,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
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) {
@ -191,8 +193,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
Task(priority: .userInitiated) {
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
}
}
if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
@ -225,7 +229,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
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 {
let direction: AccountSwitchingContainerViewController.AnimationDirection
if animated,
@ -235,9 +240,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
direction = .none
}
container.setRoot(newRoot, for: account, animating: direction)
container.setRoot(createAppUI, for: account, animating: direction)
} 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()
if UserAccountsManager.shared.onboardingComplete {
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
container.removeAccount(account)
}
} else {
window!.rootViewController = createOnboardingUI()
}

View File

@ -64,9 +64,15 @@ struct AddReactionView: View {
}
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
.searchPresentationToolbarBehaviorIfAvailable()
#if os(visionOS)
.onChange(of: query) {
updateFilteredEmojis()
}
#else
.onChange(of: query) { _ in
updateFilteredEmojis()
}
#endif
.navigationTitle("Add Reaction")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@ -166,6 +172,7 @@ private struct AddReactionButton<Label: View>: View {
private extension View {
@available(iOS, obsoleted: 16.0)
@available(visionOS 1.0, *)
@ViewBuilder
func mediumPresentationDetentIfAvailable() -> some View {
if #available(iOS 16.0, *) {
@ -176,6 +183,7 @@ private extension View {
}
@available(iOS, obsoleted: 17.1)
@available(visionOS 1.0, *)
@ViewBuilder
func searchPresentationToolbarBehaviorIfAvailable() -> some View {
if #available(iOS 17.1, *) {

View File

@ -364,7 +364,9 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
conv.showStatusesAutomatically = showStatusesAutomatically
show(conv)
} 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: _):
let indexPathBeforeExpandThread = IndexPath(row: indexPath.row - 1, section: indexPath.section)

View File

@ -221,10 +221,16 @@ class ConversationViewController: UIViewController {
completionHandler(nil)
}
}
if isLikelyMastodonRemoteStatus(url: url),
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
if isLikelyMastodonRemoteStatus(url: url) {
var request = URLRequest(url: url)
// Mastodon uses an intermediate redirect page for browsers which requires user input that we don't want.
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 {
effectiveURL = WebURL(url)!.serialized(excludingFragment: true)
}
@ -232,9 +238,14 @@ class ConversationViewController: UIViewController {
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
do {
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()
}
let status = statuses[0]
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id

View File

@ -13,7 +13,7 @@ struct CustomizeTimelinesView: View {
let mastodonController: MastodonController
var body: some View {
CustomizeTimelinesList()
CustomizeTimelinesList(pinnedTimelines: mastodonController.accountPreferences.pinnedTimelines)
.environmentObject(mastodonController)
.environment(\.managedObjectContext, mastodonController.persistentContainer.viewContext)
}
@ -26,7 +26,15 @@ struct CustomizeTimelinesList: View {
@FetchRequest(sortDescriptors: []) private var filters: FetchedResults<FilterMO>
@Environment(\.dismiss) private var dismiss
@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 {
if #available(iOS 16.0, *) {
NavigationStack {
@ -50,8 +58,12 @@ struct CustomizeTimelinesList: View {
private var navigationBody: some View {
List {
PinnedTimelinesView(accountPreferences: mastodonController.accountPreferences)
.appGroupedListRowBackground()
PinnedTimelinesView(
pinnedTimelines: $pinnedTimelines,
isShowingAddHashtagSheet: $isShowingAddHashtagSheet,
isShowingAddInstanceSheet: $isShowingAddInstanceSheet
)
.appGroupedListRowBackground()
Section {
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
Button("OK") {
self.deletionError = nil

View File

@ -11,17 +11,10 @@ import Pachyderm
struct PinnedTimelinesView: View {
@EnvironmentObject private var mastodonController: MastodonController
@ObservedObject private var accountPreferences: AccountPreferences
@State private var isShowingAddHashtagSheet = false
@State private var isShowingAddInstanceSheet = false
// store this separately from AccountPreferences in the view, b/c the @LazilyDecoding wrapper breaks animations
@State private var pinnedTimelines: [PinnedTimeline]
init(accountPreferences: AccountPreferences) {
self.accountPreferences = accountPreferences
self.pinnedTimelines = accountPreferences.pinnedTimelines
}
@Binding var pinnedTimelines: [PinnedTimeline]
@Binding var isShowingAddHashtagSheet: Bool
@Binding var isShowingAddInstanceSheet: Bool
var body: some View {
Section {
@ -110,42 +103,53 @@ struct PinnedTimelinesView: View {
} header: {
Text("Pinned Timelines")
}
.sheet(isPresented: $isShowingAddHashtagSheet, content: {
#if os(visionOS)
AddHashtagPinnedTimelineView(pinnedTimelines: $pinnedTimelines)
.edgesIgnoringSafeArea(.bottom)
#else
if #available(iOS 16.0, *) {
}
}
struct PinnedTimelinesModifier: ViewModifier {
let accountPreferences: AccountPreferences
@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)
.edgesIgnoringSafeArea(.bottom)
} else {
AddHashtagPinnedTimelineRepresentable(pinnedTimelines: $pinnedTimelines)
#else
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)
})
.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
})
.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
}
}

View File

@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController!
searchController = MastodonSearchController(searchResultsController: resultsController)
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
definesPresentationContext = true
navigationItem.searchController = searchController

View File

@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = MastodonSearchController(searchResultsController: resultsController)
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
searchController.obscuresBackgroundDuringPresentation = true
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true

View File

@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} 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)))
}
}

View File

@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
private func loadTrendingStatuses() async {
let statuses: [Status]
do {
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
} catch {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await MainActor.run {

View File

@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
let linksReq = Client.getTrendingLinks(limit: 10)
async let links = try? mastodonController.run(linksReq).0
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 snapshot.sectionIdentifiers.contains(.profileSuggestions) {
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
do {
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)
@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
@MainActor
private func removeProfileSuggestion(accountID: String) async {
let req = Suggestion.remove(accountID: accountID)
do {
_ = try await mastodonController.run(req)
var snapshot = dataSource.snapshot()
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: 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(accountID, .global)])
await apply(snapshot: snapshot)
} 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)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
await service.run()
}
}

View File

@ -204,14 +204,19 @@ class FastAccountSwitcherViewController: UIViewController {
@objc private func handleLongPress(_ recognizer: UIGestureRecognizer) {
switch recognizer.state {
case .began:
show()
#if !os(visionOS)
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
if #available(iOS 17.5, *) {
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()
#endif
show()
case .changed:
let location = recognizer.location(in: view)
@ -260,7 +265,11 @@ class FastAccountSwitcherViewController: UIViewController {
#if !os(visionOS)
if hapticFeedback {
selectionChangedFeedbackGenerator?.selectionChanged()
if #available(iOS 17.5, *) {
selectionChangedFeedbackGenerator?.selectionChanged(at: location)
} else {
selectionChangedFeedbackGenerator?.selectionChanged()
}
selectionChangedFeedbackGenerator?.prepare()
}
#endif

View File

@ -98,7 +98,7 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
case .unknown:
return LoadingGalleryContentViewController(caption: nil) {
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)
try data.write(to: url)
return FallbackGalleryNavigationController(url: url)

View File

@ -120,6 +120,15 @@ class VideoControlsViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let timestampObserverToken {
player.removeTimeObserver(timestampObserverToken)
}
if let scrubberObserverToken {
player.removeTimeObserver(scrubberObserverToken)
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -256,15 +265,18 @@ private class VideoScrubbingControl: UIControl {
private func updateFillLayerMask() {
// 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)
CATransaction.commit()
fillMaskLayer.removeAllAnimations()
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
touchStartLocation = touch.location(in: self)
scrubbingStartFraction = fractionComplete
if touch.type == .pencil || touch.type == .indirectPointer {
touchStartLocation = .zero
scrubbingStartFraction = 0
} else {
touchStartLocation = touch.location(in: self)
scrubbingStartFraction = fractionComplete
}
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear)
animator!.addAnimations {
@ -275,17 +287,28 @@ private class VideoScrubbingControl: UIControl {
sendActions(for: .editingDidBegin)
#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()
#endif
updateScrubbing(for: touch)
return true
}
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
updateScrubbing(for: touch)
return true
}
private func updateScrubbing(for touch: UITouch) {
guard let touchStartLocation,
let scrubbingStartFraction else {
return false
return
}
let location = touch.location(in: self)
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))
#if !os(visionOS)
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
fractionComplete = newFractionComplete
@ -311,8 +338,6 @@ private class VideoScrubbingControl: UIControl {
transform = CGAffineTransform(scaleX: 1 + stretchAmount / bounds.width, y: 1 + 0.5 * (1 - stretchFactor))
.translatedBy(x: sign(unclampedFractionComplete) * stretchAmount / 2, y: 0)
}
return true
}
override func endTracking(_ touch: UITouch?, with event: UIEvent?) {

View File

@ -29,6 +29,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
private var rateObservation: NSKeyValueObservation?
private var isFirstAppearance = true
private var hideControlsWorkItem: DispatchWorkItem?
private var audioSessionToken: AudioSessionCoordinator.Token?
init(url: URL, caption: String?) {
self.url = url
@ -172,10 +173,7 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
let wasFirstAppearance = isFirstAppearance
isFirstAppearance = false
DispatchQueue.global(qos: .userInitiated).async {
try? AVAudioSession.sharedInstance().setCategory(.playback)
try? AVAudioSession.sharedInstance().setActive(true)
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .video) {
if wasFirstAppearance {
DispatchQueue.main.async {
self.player.play()
@ -187,8 +185,8 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
func galleryContentWillDisappear() {
player.pause()
DispatchQueue.global(qos: .userInitiated).async {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}

View File

@ -106,6 +106,9 @@ class VideoOverlayViewController: UIViewController {
if player.rate > 0 {
player.rate = 0
} else {
if player.currentTime() >= player.currentItem!.duration {
player.seek(to: .zero)
}
#if os(visionOS)
player.play()
#else

View File

@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
let mastodonController: MastodonController
private let predicate: (StatusMO) -> Bool
private let predicateTitle: String
private let request: (RequestRange) -> Request<[Status]>
private let request: (RequestRange) -> Request<[TryDecode<Status>]>
var collectionView: UICollectionView! {
view as? UICollectionView
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
private var newer: 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.predicate = predicate
self.predicateTitle = predicateTitle
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do {
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
older = pagination?.older
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do {
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
await mastodonController.persistentContainer.addAll(statuses: statuses)
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
Task {
do {
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
await mastodonController.persistentContainer.addAll(statuses: statuses)

View File

@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
private(set) var currentAccountID: String
private(set) var root: AccountSwitchableViewController
private var userActivities: [String: NSUserActivity] = [:]
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
self.currentAccountID = account.id
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
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
if direction == .none {
oldRoot.removeViewAndController()
}
if let activity = oldRoot.stateRestorationActivity() {
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.root = 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 UIAccessibility.prefersCrossFadeTransitions {
newRoot.view.alpha = 0
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
#endif
// only one edge is affected in each direction, i have no idea why
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
if direction == .upwards {
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
} else {
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
newRoot.view.transform = .identity
} completion: { (_) in
oldRoot.view.transform = .identity
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
oldRoot.removeViewAndController()
newRoot.view.layer.masksToBounds = false
}
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
root.compose(editing: draft, animated: animated, isDucked: isDucked)
}
func select(route: TuskerRoute, animated: Bool) {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
loadViewIfNeeded()
root.select(route: route, animated: animated)
root.select(route: route, animated: animated, completion: completion)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {

View File

@ -35,8 +35,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
(child as! TuskerRootViewController).getNavigationController()
}
func select(route: TuskerRoute, animated: Bool) {
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {

View File

@ -13,8 +13,8 @@ import Combine
@MainActor
protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
}
@ -43,7 +43,7 @@ class MainSidebarViewController: UIViewController {
}
var exploreTabItems: [Item] {
var items: [Item] = [.explore, .bookmarks, .favorites]
var items: [Item] = [.tab(.explore), .bookmarks, .favorites]
let snapshot = dataSource.snapshot()
for case let .list(list) in snapshot.itemIdentifiers(inSection: .lists) {
items.append(.list(list))
@ -57,7 +57,7 @@ class MainSidebarViewController: UIViewController {
return items
}
private(set) var previouslySelectedItem: Item?
private var previouslySelectedItem: Item?
var selectedItem: Item? {
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
return nil
@ -170,7 +170,7 @@ class MainSidebarViewController: UIViewController {
snapshot.appendItems([
.tab(.timelines),
.tab(.notifications),
.explore,
.tab(.explore),
.bookmarks,
.favorites,
.tab(.myProfile)
@ -261,19 +261,21 @@ class MainSidebarViewController: UIViewController {
}
private func returnToPreviousItem() {
let item = previouslySelectedItem ?? .tab(.timelines)
let oldItem = selectedItem
let newItem = previouslySelectedItem ?? .tab(.timelines)
previouslySelectedItem = nil
select(item: item, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: item)
select(item: newItem, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem)
}
private func showAddList() {
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
) }) { list in
let oldItem = self.selectedItem
self.select(item: .list(list), animated: false)
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
list.presentEditOnAppear = true
self.sidebarDelegate?.sidebar(self, showViewController: list)
self.sidebarDelegate?.sidebar(self, showViewController: list, previousItem: oldItem)
}
service.run()
}
@ -300,7 +302,7 @@ class MainSidebarViewController: UIViewController {
return UserActivityManager.checkNotificationsActivity(mode: Preferences.shared.defaultNotificationsMode, accountID: id)
case .tab(.compose):
return UserActivityManager.newPostActivity(accountID: id)
case .explore:
case .tab(.explore):
return UserActivityManager.searchActivity(query: nil, accountID: id)
case .bookmarks:
return UserActivityManager.bookmarksActivity(accountID: id)
@ -338,7 +340,7 @@ extension MainSidebarViewController {
}
enum Item: Hashable {
case tab(MainTabBarViewController.Tab)
case explore, bookmarks, favorites
case bookmarks, favorites
case listsHeader, list(List), addList
case savedHashtagsHeader, savedHashtag(String), addSavedHashtag
case savedInstancesHeader, savedInstance(URL), addSavedInstance
@ -347,8 +349,6 @@ extension MainSidebarViewController {
switch self {
case let .tab(tab):
return tab.title
case .explore:
return "Explore"
case .bookmarks:
return "Bookmarks"
case .favorites:
@ -378,8 +378,6 @@ extension MainSidebarViewController {
switch self {
case let .tab(tab):
return tab.imageName
case .explore:
return "magnifyingglass"
case .bookmarks:
return "bookmark"
case .favorites:
@ -471,7 +469,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
fatalError("unreachable")
}
} else {
sidebarDelegate?.sidebar(self, didSelectItem: item)
sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem)
}
}
@ -540,8 +538,9 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) {
dismiss(animated: true) {
let oldItem = self.selectedItem
self.select(item: .savedInstance(url), animated: true)
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url), previousItem: oldItem)
}
}

View File

@ -86,7 +86,7 @@ class MainSplitViewController: UISplitViewController {
// 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
if traitCollection.horizontalSizeClass != .compact {
select(item: .tab(.timelines))
doSelect(item: .tab(.timelines))
}
if UIDevice.current.userInterfaceIdiom != .mac {
@ -149,7 +149,15 @@ class MainSplitViewController: UISplitViewController {
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)
}
@ -180,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
}
@objc func handleSidebarCommandTimelines() {
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.timelines), animated: false)
select(item: .tab(.timelines))
}
@objc func handleSidebarCommandNotifications() {
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.notifications), animated: false)
select(item: .tab(.notifications))
}
@objc func handleSidebarCommandExplore() {
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.explore), animated: false)
select(item: .tab(.explore))
}
@objc func handleSidebarCommandBookmarks() {
select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
sidebar.select(item: .bookmarks, animated: false)
select(item: .bookmarks)
}
@objc func handleSidebarCommandMyProfile() {
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.myProfile), animated: false)
select(item: .tab(.myProfile))
}
@objc private func sidebarTapped() {
@ -271,7 +279,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
$0.1 > $1.1
}
if let mostRecentExploreItem = mostRecentExploreItem?.0,
mostRecentExploreItem != .explore {
mostRecentExploreItem != .tab(.explore) {
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
exploreNav.popToRootViewController(animated: false)
@ -284,11 +292,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case nil:
break
case let .tab(tab):
// sidebar items that map 1 <-> 1 can be transferred directly
tabBarViewController.select(tab: tab, dismissPresented: false)
case .explore:
case .tab(.explore):
// 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
// 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
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)
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(_):
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
@ -388,9 +396,9 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// 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.
if tabNavigationStack.count == 1 || ((tabNavigationStack.first as? ExploreViewController)?.searchController?.isActive ?? false) {
exploreItem = .explore
exploreItem = .tab(.explore)
// 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
searchVC.loadViewIfNeeded()
let explore = tabNavigationStack.first as! ExploreViewController
@ -418,16 +426,16 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
case let instanceVC as InstanceTimelineViewController:
exploreItem = .savedInstance(instanceVC.instanceURL)
case is TrendsViewController:
exploreItem = .explore
exploreItem = .tab(.explore)
// skip transferring the ExploreViewController and TrendsViewController
skipFirst = 2
// prepend the InlineTrendsViewController
toPrepend = getOrCreateNavigationStack(item: .explore).first!
toPrepend = getOrCreateNavigationStack(item: .tab(.explore)).first!
default:
// transfer the navigation stack prepending, the existing explore VC
// if there was other stuff on the explore stack, it will get discarded
toPrepend = getOrCreateNavigationStack(item: .explore).first!
exploreItem = .explore
toPrepend = getOrCreateNavigationStack(item: .tab(.explore)).first!
exploreItem = .tab(.explore)
}
}
transferNavigationStack(from: tabNavController, to: exploreItem!, skipFirst: skipFirst, prepend: toPrepend)
@ -444,12 +452,12 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// These tabs map 1 <-> 1 with sidebar items
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
sidebar.select(item: item, animated: false)
select(item: item)
doSelect(item: item)
case .explore:
// 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)
select(item: exploreItem!)
doSelect(item: exploreItem!)
default:
return
@ -474,16 +482,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
compose(editing: nil)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
select(item: item)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) {
select(newItem: item, oldItem: previousItem)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) {
if let previousItem {
navigationStacks[previousItem] = secondaryNavController.viewControllers
}
secondaryNavController.viewControllers = [viewController]
}
@ -497,10 +502,10 @@ fileprivate extension MainSidebarViewController.Item {
@MainActor
func createRootViewController(_ mastodonController: MastodonController) -> UIViewController? {
switch self {
case .tab(.explore):
return InlineTrendsViewController(mastodonController: mastodonController)
case let .tab(tab):
return tab.createViewController(mastodonController)
case .explore:
return InlineTrendsViewController(mastodonController: mastodonController)
case .bookmarks:
return BookmarksViewController(mastodonController: mastodonController)
case .favorites:
@ -537,14 +542,14 @@ extension MainSplitViewController: StateRestorableViewController {
}
extension MainSplitViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
guard traitCollection.horizontalSizeClass != .compact else {
tabBarViewController?.select(route: route, animated: animated)
tabBarViewController?.select(route: route, animated: animated, completion: completion)
return
}
guard presentedViewController == nil else {
dismiss(animated: animated) {
self.select(route: route, animated: animated)
self.select(route: route, animated: animated, completion: completion)
}
return
}
@ -557,7 +562,7 @@ extension MainSplitViewController: TuskerRootViewController {
case .myProfile:
item = .tab(.myProfile)
case .explore:
item = .explore
item = .tab(.explore)
case .bookmarks:
item = .bookmarks
case .list(id: let id):
@ -567,8 +572,10 @@ extension MainSplitViewController: TuskerRootViewController {
return
}
}
let oldItem = sidebar.selectedItem
sidebar.select(item: item, animated: false)
select(item: item)
select(newItem: item, oldItem: oldItem)
completion?()
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
@ -609,8 +616,8 @@ extension MainSplitViewController: TuskerRootViewController {
return
}
if sidebar.selectedItem != .explore {
select(item: .explore)
if sidebar.selectedItem != .tab(.explore) {
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {

View File

@ -289,7 +289,7 @@ extension MainTabBarViewController: StateRestorableViewController {
}
extension MainTabBarViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
switch route {
case .timelines:
select(tab: .timelines, dismissPresented: true)
@ -310,6 +310,7 @@ extension MainTabBarViewController: TuskerRootViewController {
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
}
}
completion?()
}
func getNavigationDelegate() -> TuskerNavigationDelegate? {

View File

@ -12,7 +12,7 @@ import ComposeUI
@MainActor
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
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 getNavigationDelegate() -> TuskerNavigationDelegate?
func getNavigationController() -> NavigationControllerProtocol
@ -21,33 +21,6 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
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 {
case timelines
case notifications
@ -57,33 +30,6 @@ enum TuskerRoute {
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
protocol NavigationControllerProtocol: UIViewController {
var viewControllers: [UIViewController] { get set }

View File

@ -78,18 +78,20 @@ struct MuteAccountView: View {
}
.accessibilityHidden(true)
Section {
Toggle(isOn: $muteNotifications) {
Text("Hide notifications from this person")
}
} footer: {
if muteNotifications {
Text("This user's posts and notifications will be hidden.")
} else {
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
if mastodonController.instanceFeatures.muteNotifications {
Section {
Toggle(isOn: $muteNotifications) {
Text("Hide notifications from this person")
}
} footer: {
if muteNotifications {
Text("This user's posts and notifications will be hidden.")
} else {
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
}
}
.appGroupedListRowBackground()
}
.appGroupedListRowBackground()
Section {
Picker(selection: $duration) {

View File

@ -95,7 +95,7 @@ class NotificationLoadingViewController: UIViewController {
return
}
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
}

View File

@ -135,12 +135,11 @@ private struct MockStatusCardView: UIViewRepresentable {
func makeUIView(context: Context) -> StatusCardView {
let view = StatusCardView()
view.isUserInteractionEnabled = false
let card = Card(
let card = StatusCardView.CardData(
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")!,
kind: .link
title: "Tusker",
description: "Tusker is an iOS app for Mastodon"
)
view.updateUI(card: card, sensitive: false)
return view

View File

@ -90,7 +90,7 @@ struct PushInstanceSettingsView: View {
let mastodonController = await MastodonController.getForAccount(account)
do {
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
return true
} catch {
@ -112,7 +112,7 @@ struct PushInstanceSettingsView: View {
let mastodonController = await MastodonController.getForAccount(account)
do {
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)
subscription?.alerts = alerts
subscription?.policy = policy

View File

@ -84,7 +84,9 @@ struct TipJarView: View {
private var productsView: some View {
if isLoaded {
VStack {
supporterSubscriptions
if !supporterProducts.isEmpty {
supporterSubscriptions
}
tipPurchases
@ -419,6 +421,7 @@ private class UbiquitousKeyValueStoreObserver: ObservableObject {
private extension View {
@available(iOS, obsoleted: 17.0)
@available(visionOS 1.0, *)
@ViewBuilder
func manageSubscriptionsSheetIfAvailable(isPresented: Binding<Bool>, subscriptionGroupID: String) -> some View {
if #available(iOS 17.0, *) {

View File

@ -249,13 +249,6 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
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 tryLoadPinned()
@ -297,7 +290,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
}
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
mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -513,7 +506,7 @@ extension ProfileStatusesViewController {
extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
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 {
case .statuses:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
@ -526,7 +519,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [String] {
let request = request()
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil)
@ -546,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
}
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 {
throw Error.allCaughtUp
@ -567,7 +560,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
}
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 {
return []

View File

@ -9,6 +9,12 @@
import UIKit
import Pachyderm
import Combine
import OSLog
#if canImport(Sentry)
import Sentry
#endif
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ProfileViewController")
class ProfileViewController: UIViewController, StateRestorableViewController {
@ -120,6 +126,19 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
guard let accountID else {
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) {
updateAccountUI(account: account)
} else {

View File

@ -53,7 +53,7 @@ struct ReportAddStatusView: View {
.task { @MainActor in
do {
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)
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
} catch {

View File

@ -12,6 +12,7 @@ import SwiftUI
private var converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
fontMetrics: .default,
color: .label,
paragraphStyle: .default
)

View File

@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
super.searchResultsController as! SearchResultsViewController
}
init(searchResultsController: SearchResultsViewController) {
private weak var owner: UIViewController?
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
self.owner = owner
super.init(searchResultsController: searchResultsController)
searchResultsController.tokenHandler = { [unowned self] token, op in
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
}
}
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
var multiColumnNavigationTargetViewController: UIViewController? {
owner
}
}
extension UISearchBar {
var searchQueryWithOperators: String {
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }

View File

@ -266,7 +266,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
guard self.currentQuery == query else { return }
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
addAccounts(results.accounts)
addStatuses(results.statuses)
addStatuses(results.statuses.compactMap(\.value))
} completion: {
DispatchQueue.main.async {
self.showSearchResults(results)
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
}
if !results.statuses.isEmpty && resultTypes.contains(.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)
@ -414,7 +414,7 @@ extension SearchResultsViewController {
hasher.combine(id)
case let .hashtag(hashtag):
hasher.combine("hashtag")
hasher.combine(hashtag.url)
hasher.combine(hashtag.name)
case let .status(id, _):
hasher.combine("status")
hasher.combine(id)

View File

@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
addInteraction(UIPointerInteraction(delegate: self))
}
required init?(coder: NSCoder) {
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.text = text
}
}
extension SearchTokenSuggestionCollectionViewCell: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: self)
return UIPointerStyle(effect: .lift(preview))
}
}

View File

@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
do {
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 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
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [TimelineItem] {
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
mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
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 {
throw TimelineViewController.Error.allCaughtUp
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
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 {
return []
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
}
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 {
return []

View File

@ -150,9 +150,16 @@ class CustomAlertActionsView: UIControl {
private var separatorSizeConstraints: [NSLayoutConstraint] = []
#if !os(visionOS)
private let generator = UISelectionFeedbackGenerator()
private lazy var generator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif
private var currentSelectedActionIndex: Int?
private var showPressedMenuWorkItem: DispatchWorkItem?
init(config: CustomAlertController.Configuration, dismiss: @escaping () -> Void) {
self.dismiss = dismiss
@ -313,13 +320,42 @@ class CustomAlertActionsView: UIControl {
actionButtons[currentSelectedActionIndex].backgroundColor = nil
}
#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
if let showPressedMenuWorkItem {
showPressedMenuWorkItem.cancel()
self.showPressedMenuWorkItem = nil
}
}
currentSelectedActionIndex = selectedButton?.offset
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)
generator.prepare()
#endif

View File

@ -8,17 +8,26 @@
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 {
private var isManuallyUpdating = false
var viewControllers: [UIViewController] = [] {
didSet {
guard isViewLoaded,
!isManuallyUpdating else {
return
private var _viewControllers: [UIViewController] = []
var viewControllers: [UIViewController] {
get {
_viewControllers
}
set {
_viewControllers = newValue
if isViewLoaded {
updateViews()
scrollToEnd(animated: false)
}
updateViews()
scrollToEnd(animated: false)
}
}
@ -68,13 +77,13 @@ class MultiColumnNavigationController: UIViewController {
private func updateViews() {
var i = 0
while i < viewControllers.count {
while i < _viewControllers.count {
let needsCloseButton = i > 0
if i <= stackView.arrangedSubviews.count - 1 {
let existing = stackView.arrangedSubviews[i] as! ColumnView
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton)
existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
} else {
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton)
let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
stackView.addArrangedSubview(new)
}
i += 1
@ -92,9 +101,11 @@ class MultiColumnNavigationController: UIViewController {
var index: Int? = nil
var current: UIViewController? = sender
while let c = current {
index = viewControllers.firstIndex(of: c)
index = _viewControllers.firstIndex(of: c)
if index != nil {
break
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
current = targetProviding.multiColumnNavigationTargetViewController
} else {
current = c.parent
}
@ -112,24 +123,32 @@ class MultiColumnNavigationController: UIViewController {
}
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)
} else {
viewControllers = Array(viewControllers[...afterIndex]) + vcs
_viewControllers = Array(_viewControllers[...afterIndex]) + vcs
updateViews()
scrollToEnd(animated: animated)
}
}
private func scrollToEnd(animated: Bool) {
if viewControllers.isEmpty {
if _viewControllers.isEmpty {
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
} else {
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated)
scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
}
}
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()
if animated {
scrollView.contentOffset = origContentOffset
}
let column = stackView.arrangedSubviews[columnIndex]
let columnFrame = column.convert(column.bounds, to: scrollView)
let offset: CGFloat
@ -142,14 +161,12 @@ class MultiColumnNavigationController: UIViewController {
}
fileprivate func closeColumn(_ vc: UIViewController) {
let index = viewControllers.firstIndex(of: vc)!
guard index > 0 else {
guard let index = _viewControllers.firstIndex(of: vc),
index > 0 else {
// Can't close the last column
return
}
isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.removeSubrange(index...)
_viewControllers.removeSubrange(index...)
animateChanges {
for column in self.stackView.arrangedSubviews[index...] {
column.layer.opacity = 0
@ -158,7 +175,6 @@ class MultiColumnNavigationController: UIViewController {
} completion: {
self.updateViews()
}
}
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
@ -173,19 +189,22 @@ class MultiColumnNavigationController: UIViewController {
extension MultiColumnNavigationController: NavigationControllerProtocol {
var topViewController: UIViewController? {
viewControllers.last
_viewControllers.last
}
func popToRootViewController(animated: Bool) -> [UIViewController]? {
let removed = Array(viewControllers.dropFirst())
viewControllers = [viewControllers.first!]
guard !_viewControllers.isEmpty else {
return nil
}
let removed = Array(_viewControllers.dropFirst())
_viewControllers = [_viewControllers.first!]
updateViews()
scrollToEnd(animated: animated)
return removed
}
func pushViewController(_ vc: UIViewController, animated: Bool) {
isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.append(vc)
_viewControllers.append(vc)
updateViews()
scrollToEnd(animated: animated)
if animated {
@ -273,12 +292,17 @@ private class ColumnView: UIView {
}
private func installCloseBarButton(navigationItem: UINavigationItem) {
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
item.accessibilityLabel = "Close Column"
if navigationItem.leftBarButtonItems != nil {
navigationItem.leftBarButtonItems!.insert(item, at: 0)
func makeItem() -> UIBarButtonItem {
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
item.accessibilityLabel = "Close Column"
return item
}
if let leftItems = navigationItem.leftBarButtonItems {
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
}
} else {
navigationItem.leftBarButtonItems = [item]
navigationItem.leftBarButtonItems = [makeItem()]
}
}

View File

@ -557,11 +557,15 @@ extension MenuActionProvider {
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
} else {
let image = UIImage(systemName: "circle.slash")
return UIMenu(title: "Block", image: image, children: [
var children = [
UIAction(title: "Cancel", handler: { _ in }),
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
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
guard relationship.following || relationship.showingReblogs else {
guard relationship.following || relationship.showingReblogs,
mastodonController.instanceFeatures.hideReblogs else {
return nil
}
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"

View File

@ -50,8 +50,11 @@ extension UIViewController {
}
func removeViewAndController() {
beginAppearanceTransition(false, animated: false)
view.removeFromSuperview()
willMove(toParent: nil)
removeFromParent()
endAppearanceTransition()
}
}
@ -68,7 +71,7 @@ extension UIView {
if layout {
subview.frame = bounds
subview.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),

View File

@ -44,9 +44,9 @@ enum AppShortcutItem: String, CaseIterable {
}
switch self {
case .showHomeTimeline:
root.select(route: .timelines, animated: false)
root.select(route: .timelines, animated: false, completion: nil)
case .showNotifications:
root.select(route: .notifications, animated: false)
root.select(route: .notifications, animated: false, completion: nil)
case .composePost:
root.compose(editing: nil, animated: false, isDucked: false)
}

View File

@ -39,12 +39,13 @@ extension NSUserActivity {
self.userInfo = [
"accountID": accountID
]
self.targetContentIdentifier = accountID
}
@MainActor
func handleResume(manager: UserActivityManager) -> Bool {
func handleResume(manager: UserActivityManager) async -> Bool {
guard let type = UserActivityType(rawValue: activityType) else { return false }
type.handle(manager)(self)
await type.handle(manager)(self)
return true
}

View File

@ -16,7 +16,8 @@ import ComposeUI
protocol UserActivityHandlingContext {
var isHandoff: Bool { get }
func select(route: TuskerRoute)
func select(route: TuskerRoute) async
func select(route: TuskerRoute, completion: (() -> Void)?)
func present(_ vc: UIViewController)
var topViewController: UIViewController? { get }
@ -28,6 +29,16 @@ protocol UserActivityHandlingContext {
func finalize(activity: NSUserActivity)
}
extension UserActivityHandlingContext {
func select(route: TuskerRoute) async {
await withCheckedContinuation { continuation in
select(route: route) {
continuation.resume()
}
}
}
}
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
let isHandoff: Bool
let root: TuskerRootViewController
@ -35,8 +46,8 @@ struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
root.getNavigationDelegate()!
}
func select(route: TuskerRoute) {
root.select(route: route, animated: true)
func select(route: TuskerRoute, completion: (() -> Void)?) {
root.select(route: route, animated: true, completion: completion)
}
func present(_ vc: UIViewController) {
@ -71,9 +82,11 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
var isHandoff: Bool { false }
func select(route: TuskerRoute) {
root.select(route: route, animated: false)
state = .selectedRoute
func select(route: TuskerRoute, completion: (() -> Void)?) {
root.select(route: route, animated: false) {
self.state = .selectedRoute
completion?()
}
}
var topViewController: UIViewController? { root.getNavigationController().topViewController }

View File

@ -133,8 +133,8 @@ class UserActivityManager {
return activity
}
func handleCheckNotifications(activity: NSUserActivity) {
context.select(route: .notifications)
func handleCheckNotifications(activity: NSUserActivity) async {
await context.select(route: .notifications)
context.popToRoot()
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
notificationsPageController.loadViewIfNeeded()
@ -204,22 +204,22 @@ class UserActivityManager {
return (timeline, positionInfo)
}
func handleShowTimeline(activity: NSUserActivity) {
func handleShowTimeline(activity: NSUserActivity) async {
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
var timelineVC: TimelineViewController?
if let pinned = PinnedTimeline(timeline: timeline),
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
context.select(route: .timelines)
await context.select(route: .timelines)
context.popToRoot()
let pageController = context.topViewController as! TimelinesPageViewController
pageController.selectTimeline(pinned, animated: false)
timelineVC = pageController.currentViewController as? TimelineViewController
} 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
} else {
context.select(route: .explore)
await context.select(route: .explore)
context.popToRoot()
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
context.push(timelineVC!)
@ -249,11 +249,11 @@ class UserActivityManager {
return activity.userInfo?["mainStatusID"] as? String
}
func handleShowConversation(activity: NSUserActivity) {
func handleShowConversation(activity: NSUserActivity) async {
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
return
}
context.select(route: .timelines)
await context.select(route: .timelines)
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
}
@ -274,8 +274,8 @@ class UserActivityManager {
return activity.userInfo?["query"] as? String
}
func handleSearch(activity: NSUserActivity) {
context.select(route: .explore)
func handleSearch(activity: NSUserActivity) async {
await context.select(route: .explore)
context.popToRoot()
let searchController: UISearchController
@ -311,8 +311,8 @@ class UserActivityManager {
return activity
}
func handleBookmarks(activity: NSUserActivity) {
context.select(route: .bookmarks)
func handleBookmarks(activity: NSUserActivity) async {
await context.select(route: .bookmarks)
}
// MARK: - My Profile
@ -325,8 +325,8 @@ class UserActivityManager {
return activity
}
func handleMyProfile(activity: NSUserActivity) {
context.select(route: .myProfile)
func handleMyProfile(activity: NSUserActivity) async {
await context.select(route: .myProfile)
}
// MARK: - Show Profile
@ -344,11 +344,11 @@ class UserActivityManager {
return activity.userInfo?["profileID"] as? String
}
func handleShowProfile(activity: NSUserActivity) {
func handleShowProfile(activity: NSUserActivity) async {
guard let accountID = Self.getProfile(from: activity) else {
return
}
context.select(route: .timelines)
await context.select(route: .timelines)
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
}
@ -361,11 +361,11 @@ class UserActivityManager {
return activity
}
func handleShowNotification(activity: NSUserActivity) {
func handleShowNotification(activity: NSUserActivity) async {
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
return
}
context.select(route: .notifications)
await context.select(route: .notifications)
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
}

View File

@ -23,7 +23,7 @@ enum UserActivityType: String {
extension UserActivityType {
@MainActor
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
switch self {
case .mainScene:
fatalError("cannot handle main scene activity")

View File

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

View File

@ -16,7 +16,7 @@ class AccountDisplayNameLabel: EmojiLabel {
private var accountDisplayName: String?
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
}
accountID = account.id

View File

@ -380,6 +380,8 @@ class AttachmentView: GIFImageView {
self.badgeContainer = stack
stack.axis = .horizontal
stack.spacing = 2
// badges should appear on top of any subsequently added views (e.g., gifv)
stack.layer.zPosition = 100
stack.translatesAutoresizingMaskIntoConstraints = false
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 {
itemSource = VideoActivityItemSource(asset: AVAsset(url: self.attachment.url), url: self.attachment.url)
itemData = Task {
try? await URLSession.shared.data(from: self.attachment.url).0
try? await URLSession.appDefault.data(from: self.attachment.url).0
}
} else {
return nil

View File

@ -17,6 +17,7 @@ class GifvController {
let player: AVPlayer
private var isGrayscale = false
private var audioSessionToken: AudioSessionCoordinator.Token?
let presentationSizeSubject = PassthroughSubject<CGSize, Never>()
private var presentationSizeObservation: NSKeyValueObservation?
@ -29,7 +30,9 @@ class GifvController {
self.isGrayscale = Preferences.shared.grayscaleImages
player.isMuted = true
#if !os(visionOS)
#if os(visionOS)
player.preventsAutomaticBackgroundingDuringVideoPlayback = false
#else
player.preventsDisplaySleepDuringVideoPlayback = false
#endif
@ -41,12 +44,19 @@ class GifvController {
func play() {
if player.rate == 0 {
player.play()
audioSessionToken = AudioSessionCoordinator.shared.beginPlayback(mode: .gifv) {
DispatchQueue.main.async {
self.player.play()
}
}
}
}
func pause() {
player.pause()
if let audioSessionToken {
AudioSessionCoordinator.shared.endPlayback(token: audioSessionToken)
}
}
private func updatePresentationSizeObservation() {

View File

@ -22,6 +22,7 @@ class GifvPlayerView: UIView {
let controller: GifvController
private var presentationSizeCancellable: AnyCancellable?
private var wasPlayingWhenSceneBackgrounded = false
override var intrinsicContentSize: CGSize {
controller.item.presentationSize
@ -45,4 +46,30 @@ class GifvPlayerView: UIView {
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()
}
}
}

View File

@ -33,6 +33,11 @@ class CachedImageView: UIImageView {
commonInit()
}
deinit {
fetchTask?.cancel()
blurHashTask?.cancel()
}
private func commonInit() {
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}

View File

@ -13,6 +13,7 @@ class ConfirmReblogStatusPreviewView: UIView {
private static let htmlConverter = HTMLConverter(
font: .preferredFont(forTextStyle: .caption2),
monospaceFont: UIFontMetrics(forTextStyle: .caption2).scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
fontMetrics: UIFontMetrics(forTextStyle: .caption2),
color: .label,
paragraphStyle: .default
)

View File

@ -25,6 +25,7 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
private static let defaultBodyHTMLConverter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
fontMetrics: .default,
color: .label,
paragraphStyle: .default
)
@ -36,6 +37,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
var emojiTextColor: UIColor = .label
private let tapRecognizer = UITapGestureRecognizer()
// The link range currently being previewed
private var currentPreviewedLinkRange: NSRange?
// 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()
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
addGestureRecognizer(recognizer)
tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
tapRecognizer.delegate = self
addGestureRecognizer(tapRecognizer)
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
underlineTextLinksCancellable =
@ -132,12 +136,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
@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)
if let (link, range) = getLinkAtPoint(location),
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
}
}
}

View File

@ -14,6 +14,7 @@ class PollOptionView: UIView {
private static let minHeight: CGFloat = 35
private static let cornerRadius = 0.1 * minHeight
private static let unselectedBackgroundColor = UIColor(white: 0.5, alpha: 0.25)
private static let hoveredBackgroundColor = UIColor(white: 0.35, alpha: 0.25)
private(set) var label: EmojiLabel!
@Lazy private var checkbox: PollOptionCheckboxView = PollOptionCheckboxView().configure {
@ -33,6 +34,12 @@ class PollOptionView: UIView {
private var labelLeadingToSelfConstraint: NSLayoutConstraint!
private var fillViewWidthConstraint: NSLayoutConstraint?
var hovered: Bool = false {
didSet {
backgroundColor = hovered ? PollOptionView.hoveredBackgroundColor : PollOptionView.unselectedBackgroundColor
}
}
init() {
super.init(frame: .zero)

View File

@ -24,12 +24,19 @@ class PollOptionsView: UIControl {
private var poll: Poll!
private var animator: UIViewPropertyAnimator!
private var currentSelectedOptionIndex: Int?
private var currentHoveredOptionIndex: Int?
private let animationDuration: TimeInterval = 0.1
private let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
static let animationDuration: TimeInterval = 0.1
static let scaledTransform = CGAffineTransform(scaleX: 0.95, y: 0.95)
#if !os(visionOS)
private let generator = UISelectionFeedbackGenerator()
private lazy var generator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif
override var isEnabled: Bool {
@ -59,6 +66,8 @@ class PollOptionsView: UIControl {
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(hoverRecognized)))
}
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
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) {
currentSelectedOptionIndex = index
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
view.transform = self.scaledTransform
if animator?.isRunning == true {
animator.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
view.transform = Self.scaledTransform
view.hovered = true
}
animator.startAnimation()
#if !os(visionOS)
generator.selectionChanged()
if #available(iOS 17.5, *) {
generator.selectionChanged(at: view.center)
} else {
generator.selectionChanged()
}
generator.prepare()
#endif
@ -151,30 +182,31 @@ class PollOptionsView: UIControl {
override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
let location = touch.location(in: self)
var newIndex: Int? = nil
for (index, view) in options.enumerated() {
var frame = view.frame
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
}
}
let newIndexAndOption = optionView(at: location)
let newIndex = newIndexAndOption?.1
let option = newIndexAndOption?.0
if newIndex != currentSelectedOptionIndex {
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() {
view.transform = index == newIndex ? self.scaledTransform : .identity
view.transform = index == newIndex ? Self.scaledTransform : .identity
view.hovered = index == newIndex
}
}
animator.startAnimation()
#if !os(visionOS)
if newIndex != nil {
generator.selectionChanged()
if let option {
if #available(iOS 17.5, *) {
generator.selectionChanged(at: option.center)
} else {
generator.selectionChanged()
}
generator.prepare()
}
#endif
@ -189,14 +221,15 @@ class PollOptionsView: UIControl {
func selectOption() {
guard let index = currentSelectedOptionIndex else { return }
let option = options[index]
animator = UIViewPropertyAnimator(duration: animationDuration, curve: .easeInOut) {
animator = UIViewPropertyAnimator(duration: Self.animationDuration, curve: .easeInOut) {
option.transform = .identity
option.hovered = false
self.selectOption(option)
}
animator.startAnimation()
}
if animator.isRunning {
if animator?.isRunning == true {
animator.addCompletion { (_) in
selectOption()
}
@ -207,12 +240,52 @@ class PollOptionsView: UIControl {
override func cancelTracking(with event: UIEvent?) {
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 {
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
}
}
}

View File

@ -65,6 +65,7 @@ class StatusPollView: UIView, StatusContentView {
addSubview(infoLabel)
voteButton = UIButton(configuration: .plain())
voteButton.isPointerInteractionEnabled = true
voteButton.translatesAutoresizingMaskIntoConstraints = false
voteButton.addTarget(self, action: #selector(votePressed), for: .touchUpInside)
voteButton.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)

View File

@ -12,20 +12,24 @@ import SwiftUI
import SafariServices
class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate?
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)),
fontMetrics: .default,
color: .label,
paragraphStyle: .default
)
private let account: AccountMO
private let field: Account.Field
private var link: (String, URL)?
private let label = EmojiLabel()
private let textView = ContentTextView()
private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview?
@ -38,34 +42,28 @@ class ProfileFieldValueView: UIView {
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
var range = NSRange(location: 0, length: 0)
if converted.length != 0,
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
link = (converted.attributedSubstring(from: range).string, url)
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
label.addInteraction(UIContextMenuInteraction(delegate: self))
label.isUserInteractionEnabled = true
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
guard value != nil else { return }
#if os(visionOS)
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
#else
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
#endif
// the .link attribute in a UILabel always makes the color blue >.>
converted.removeAttribute(.link, range: range)
}
}
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
#if os(visionOS)
textView.linkTextAttributes = [
.foregroundColor: UIColor.link
]
#else
textView.linkTextAttributes = [
.foregroundColor: UIColor.tintColor
]
#endif
textView.backgroundColor = nil
textView.isScrollEnabled = false
textView.isSelectable = false
textView.isEditable = false
textView.font = .preferredFont(forTextStyle: .body)
updateTextContainerInset()
textView.adjustsFontForContentSizeCategory = true
textView.attributedText = converted
textView.setEmojis(account.emojis, identifier: account.id)
textView.isUserInteractionEnabled = true
textView.setContentCompressionResistancePriority(.required, for: .vertical)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
let labelTrailingConstraint: NSLayoutConstraint
@ -82,20 +80,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
])
} else {
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor),
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint,
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@ -104,37 +102,36 @@ class ProfileFieldValueView: UIView {
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = label.sizeThatFits(size)
var size = textView.sizeThatFits(size)
if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
}
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) {
label.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
textView.textAlignment = alignment
}
@objc private func verifiedIconTapped() {
@ -144,7 +141,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: label.text ?? "",
linkText: textView.text ?? "",
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
@ -168,49 +165,3 @@ class ProfileFieldValueView: UIView {
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!)
}
}

View File

@ -14,7 +14,8 @@ class ProfileHeaderMovedOverlayView: UIView {
weak var delegate: TuskerNavigationDelegate?
var collapse: (() -> Void)?
var hide: (() -> Void)?
private var avatarImageView: CachedImageView!
private var displayNameLabel: EmojiLabel!
private var usernameLabel: UILabel!
@ -144,7 +145,46 @@ class ProfileHeaderMovedOverlayView: UIView {
@objc private func accountTapped() {
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 {

View File

@ -25,9 +25,9 @@ class ProfileHeaderView: UIView {
weak var delegate: ProfileHeaderViewDelegate?
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var headerImageView: CachedImageView!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var avatarImageView: CachedImageView!
@IBOutlet weak var moreButton: ProfileHeaderButton!
@IBOutlet weak var followButton: ProfileHeaderButton!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@ -41,11 +41,10 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var followersCountButton: UIButton!
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
private var movedOverlayView: ProfileHeaderMovedOverlayView?
private var hideMovedOverlayView = false
var accountID: String!
private var imagesTask: Task<Void, Never>?
private var isGrayscale = false
private var followButtonMode = FollowButtonMode.follow {
didSet {
@ -56,10 +55,6 @@ class ProfileHeaderView: UIView {
}
}
deinit {
imagesTask?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
@ -69,11 +64,13 @@ class ProfileHeaderView: UIView {
avatarContainerView.layer.cornerCurve = .continuous
// Set zPositions so the gallery presentation/dismissal animation looks correct.
avatarContainerView.layer.zPosition = 2
avatarImageView.cache = .avatars
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerCurve = .continuous
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
avatarImageView.isUserInteractionEnabled = true
headerImageView.cache = .headers
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
headerImageView.isUserInteractionEnabled = true
headerImageView.layer.zPosition = 1
@ -138,11 +135,11 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked
imagesTask?.cancel()
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
if let avatar = account.avatar {
avatarImageView.update(for: avatar)
}
if let header = account.header {
headerImageView.update(for: header)
}
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.accessibilityLabel = "\(followersSpelledOut) followers"
if let movedTo = account.movedTo {
if let movedTo = account.movedTo,
!hideMovedOverlayView {
if let movedOverlayView {
movedOverlayView.updateUI(movedTo: movedTo)
} else {
@ -211,6 +209,7 @@ class ProfileHeaderView: UIView {
private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView {
let overlay = ProfileHeaderMovedOverlayView()
overlay.layer.zPosition = 1000
overlay.delegate = delegate
overlay.updateUI(movedTo: movedTo)
overlay.translatesAutoresizingMaskIntoConstraints = false
@ -238,6 +237,12 @@ class ProfileHeaderView: UIView {
}
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
}
@ -294,44 +299,6 @@ class ProfileHeaderView: UIView {
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
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) {

View File

@ -3,7 +3,7 @@
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<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="System colors in document resources" minToolsVersion="11.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">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<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"/>
<constraints>
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
@ -23,7 +23,7 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
<rect key="frame" x="16" y="138" width="120" height="120"/>
<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"/>
<constraints>
<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">
<rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
<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"/>
<state key="normal" title="Button"/>
<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"/>
</connections>
</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"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="Button"/>

View File

@ -24,7 +24,13 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
private var changeSelectionPanRecognizer: UIGestureRecognizer!
private var selectedOptionAtStartOfPan: Value?
#if !os(visionOS)
private lazy var selectionChangedFeedbackGenerator = UISelectionFeedbackGenerator()
private lazy var selectionChangedFeedbackGenerator: UISelectionFeedbackGenerator = {
if #available(iOS 17.5, *) {
UISelectionFeedbackGenerator(view: self)
} else {
UISelectionFeedbackGenerator()
}
}()
#endif
override var intrinsicContentSize: CGSize {
@ -111,13 +117,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
func setSelectedOption(_ value: Value, animated: Bool) {
guard selectedOption != value,
options.contains(where: { $0.value == value }) else {
let index = options.firstIndex(where: { $0.value == value }) else {
return
}
#if !os(visionOS)
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
@ -158,15 +170,19 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
// MARK: Interaction
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer === self.panGestureRecognizer else {
return true
}
let beganOnSelectedOption: Bool
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
} else {
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
if gestureRecognizer == self.changeSelectionPanRecognizer {
return beganOnSelectedOption
@ -223,7 +239,12 @@ class ScrollingSegmentedControl<Value: Hashable>: UIScrollView, UIGestureRecogni
}
animator.startAnimation()
#if !os(visionOS)
selectionChangedFeedbackGenerator.selectionChanged()
if #available(iOS 17.5, *) {
let locationInSelf = convert(location, from: optionsStack)
selectionChangedFeedbackGenerator.selectionChanged(at: locationInSelf)
} else {
selectionChangedFeedbackGenerator.selectionChanged()
}
#endif
return true
} else {

View File

@ -20,6 +20,7 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
private static let htmlConverter = HTMLConverter(
font: ConversationMainStatusCollectionViewCell.contentFont,
monospaceFont: ConversationMainStatusCollectionViewCell.monospaceFont,
fontMetrics: .default,
color: .label,
paragraphStyle: ConversationMainStatusCollectionViewCell.contentParagraphStyle
)
@ -514,8 +515,15 @@ class ConversationMainStatusCollectionViewCell: UICollectionViewListCell, Status
return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: width)
}
func updateAccountUI(account: AccountMO) {
baseUpdateAccountUI(account: account)
displayNameLabel.updateForAccountDisplayName(account: account)
usernameLabel.text = "@\(account.acct)"
}
func updateUIForPreferences(status: StatusMO) {
baseUpdateUIForPreferences(status: status)
displayNameLabel.updateForAccountDisplayName(account: status.account)
}
@objc private func preferencesChanged() {
@ -640,7 +648,7 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
return defaultRegion
} else if let button = interaction.view as? UIButton,
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)
return UIPointerRegion(rect: rect)
}
@ -654,8 +662,8 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
} else if let button = interaction.view as? UIButton,
actionButtons.contains(button) {
let preview = UITargetedPreview(view: button.imageView!)
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
rect = rect.insetBy(dx: -24, dy: -24)
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
rect = rect.insetBy(dx: -8, dy: -8)
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
}
return nil

Some files were not shown because too many files have changed in this diff Show More