2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Notification Filtering (IOS-241) (#1319)

- Adds a new cell on the "Everything"-notification-screen to show
unwanted notifications
- Users can define what "unwanted" means to them using the new
"Filter"-button in the upper right corner of the Notification-screen
- Filtered notifications are sorted by account and users can
dismiss/accept if they want to get notifications of that user (it's some
standard table-views and delegates)

## Screenshots


![ios_241_1](https://github.com/user-attachments/assets/b2297a13-e220-4916-b3b9-24dfcef431de)


![ios_241_2](https://github.com/user-attachments/assets/5fd5b894-cd4e-40f9-abce-56ff17d41dfa)
This commit is contained in:
Nathan Mattes 2024-07-26 11:15:31 +02:00 committed by GitHub
commit 5d8e453da6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 4018 additions and 2416 deletions

File diff suppressed because it is too large Load Diff

View File

@ -477,7 +477,15 @@
"title": "Home",
"timeline_menu": {
"following": "Following",
"local_community": "Local"
"local_community": "Local",
"lists": {
"title": "Lists",
"empty_message": "You don't have any Lists"
},
"hashtags": {
"title": "Followed Hashtags",
"empty_message": "You don't follow any Hashtags"
}
},
"timeline_pill": {
"offline": "Offline",
@ -744,6 +752,30 @@
"silence": "Your account has been limited.",
"suspend": "Your account has been suspended.",
"learn_more": "Learn More"
},
"filtered_notification": {
"title": "Filtered Notifications",
"accept": "Accept",
"dismiss": "Dismiss",
},
"policy": {
"title": "Filter Notifications from…",
"not_following": {
"title": "People you don't follow",
"subtitle": "Until you manually approve them"
},
"no_follower": {
"title": "People not following you",
"subtitle": "Including people who have been following you fewer than 3 days"
},
"new_account": {
"title": "New accounts",
"subtitle": "Created within the past 30 days"
},
"private_mentions": {
"title": "Unsolicited private mentions",
"subtitle": "Filtered unless its in reply to your own mention or if you follow the sender"
}
}
},
"thread": {

View File

@ -755,6 +755,30 @@
"silence": "Your account has been limited.",
"suspend": "Your account has been suspended.",
"learn_more": "Learn More"
},
"filtered_notification": {
"title": "Filtered Notifications",
"accept": "Accept",
"dismiss": "Dismiss",
},
"policy": {
"title": "Filter Notifications from…",
"not_following": {
"title": "People you don't follow",
"subtitle": "Until you manually approve them"
},
"no_follower": {
"title": "People not following you",
"subtitle": "Including people who have been following you fewer than 3 days"
},
"new_account": {
"title": "New accounts",
"subtitle": "Created within the past 30 days"
},
"private_mentions": {
"title": "Unsolicited private mentions",
"subtitle": "Filtered unless its in reply to your own mention or if you follow the sender"
}
}
},
"thread": {

View File

@ -157,10 +157,20 @@
D8318A882A4468D300C0FB73 /* NotificationSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8318A872A4468D300C0FB73 /* NotificationSettingsViewController.swift */; };
D8318A8A2A4468DC00C0FB73 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8318A892A4468DC00C0FB73 /* AboutViewController.swift */; };
D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8363B1529469CE200A74079 /* OnboardingNextView.swift */; };
D83B54F82C2AC2FA00D18A7B /* NotificationFilteringBannerTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83B54F72C2AC2FA00D18A7B /* NotificationFilteringBannerTableViewCell.swift */; };
D84738D42BBD9ABE00ECD52B /* TimelineStatusPill.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */; };
D84FA0932AE6915800987F47 /* MBProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = D84FA0922AE6915800987F47 /* MBProgressHUD */; };
D852C23C2AC5D02C00309232 /* AboutInstanceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D852C23B2AC5D02C00309232 /* AboutInstanceViewController.swift */; };
D852C23E2AC5D03300309232 /* InstanceRulesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D852C23D2AC5D03300309232 /* InstanceRulesViewController.swift */; };
D85DF96B2C481AF700A01408 /* NotificationPolicyFilterTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF9682C481AF400A01408 /* NotificationPolicyFilterTableViewCell.swift */; };
D85DF96C2C481AF700A01408 /* NotificationPolicyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF9692C481AF700A01408 /* NotificationPolicyViewController.swift */; };
D85DF96D2C481AF700A01408 /* NotificationPolicyHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF96A2C481AF700A01408 /* NotificationPolicyHeaderView.swift */; };
D85DF9712C481B1100A01408 /* NotificationRequestsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF96E2C481B1100A01408 /* NotificationRequestsTableViewController.swift */; };
D85DF9722C481B1100A01408 /* NotificationRequestTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF96F2C481B1100A01408 /* NotificationRequestTableViewCell.swift */; };
D85DF9742C481B3500A01408 /* DataSourceFacade+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF9732C481B3500A01408 /* DataSourceFacade+Notifications.swift */; };
D85DF9762C4965A900A01408 /* NotificationRequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF9752C4965A900A01408 /* NotificationRequestsViewModel.swift */; };
D85DF97A2C4E49A400A01408 /* NotificationRequestCountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF9792C4E49A400A01408 /* NotificationRequestCountView.swift */; };
D85DF97E2C50EFA700A01408 /* AccountNotificationTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D85DF97D2C50EFA700A01408 /* AccountNotificationTimelineViewController.swift */; };
D87364F92AE28DB500C8F919 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = D87364F82AE28DB500C8F919 /* Kanna */; };
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; };
D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */; };
@ -789,6 +799,7 @@
D8318A872A4468D300C0FB73 /* NotificationSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsViewController.swift; sourceTree = "<group>"; };
D8318A892A4468DC00C0FB73 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = "<group>"; };
D8363B1529469CE200A74079 /* OnboardingNextView.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = OnboardingNextView.swift; sourceTree = "<group>"; tabWidth = 4; };
D83B54F72C2AC2FA00D18A7B /* NotificationFilteringBannerTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationFilteringBannerTableViewCell.swift; sourceTree = "<group>"; };
D84738D32BBD9ABE00ECD52B /* TimelineStatusPill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineStatusPill.swift; sourceTree = "<group>"; };
D84C099D2B0F9E33009E685E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
D84C099F2B0F9E41009E685E /* Setup.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Setup.md; sourceTree = "<group>"; };
@ -799,6 +810,15 @@
D84C09A42B0F9E41009E685E /* Acknowledgments.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = Acknowledgments.md; sourceTree = "<group>"; };
D852C23B2AC5D02C00309232 /* AboutInstanceViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutInstanceViewController.swift; sourceTree = "<group>"; };
D852C23D2AC5D03300309232 /* InstanceRulesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceRulesViewController.swift; sourceTree = "<group>"; };
D85DF9682C481AF400A01408 /* NotificationPolicyFilterTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NotificationPolicyFilterTableViewCell.swift; path = Policy/NotificationPolicyFilterTableViewCell.swift; sourceTree = "<group>"; };
D85DF9692C481AF700A01408 /* NotificationPolicyViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NotificationPolicyViewController.swift; path = Policy/NotificationPolicyViewController.swift; sourceTree = "<group>"; };
D85DF96A2C481AF700A01408 /* NotificationPolicyHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = NotificationPolicyHeaderView.swift; path = Policy/NotificationPolicyHeaderView.swift; sourceTree = "<group>"; };
D85DF96E2C481B1100A01408 /* NotificationRequestsTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationRequestsTableViewController.swift; sourceTree = "<group>"; };
D85DF96F2C481B1100A01408 /* NotificationRequestTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationRequestTableViewCell.swift; sourceTree = "<group>"; };
D85DF9732C481B3500A01408 /* DataSourceFacade+Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Notifications.swift"; sourceTree = "<group>"; };
D85DF9752C4965A900A01408 /* NotificationRequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRequestsViewModel.swift; sourceTree = "<group>"; };
D85DF9792C4E49A400A01408 /* NotificationRequestCountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationRequestCountView.swift; sourceTree = "<group>"; };
D85DF97D2C50EFA700A01408 /* AccountNotificationTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountNotificationTimelineViewController.swift; sourceTree = "<group>"; };
D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginView.swift; sourceTree = "<group>"; };
D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginViewModel.swift; sourceTree = "<group>"; };
D87BFC8E291EC26A00FEE264 /* MastodonLoginServerTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonLoginServerTableViewCell.swift; sourceTree = "<group>"; };
@ -1510,6 +1530,7 @@
DB63F76E279A7D1100455B82 /* NotificationTableViewCell.swift */,
DB63F774279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift */,
D8A0729C2BEBA8D7001A4C7C /* AccountWarningNotificationCell.swift */,
D83B54F72C2AC2FA00D18A7B /* NotificationFilteringBannerTableViewCell.swift */,
);
path = Cell;
sourceTree = "<group>";
@ -1758,6 +1779,16 @@
path = Privacy;
sourceTree = "<group>";
};
D80EC2602C2978CB009724A5 /* Notification Filtering */ = {
isa = PBXGroup;
children = (
D85DF9682C481AF400A01408 /* NotificationPolicyFilterTableViewCell.swift */,
D85DF96A2C481AF700A01408 /* NotificationPolicyHeaderView.swift */,
D85DF9692C481AF700A01408 /* NotificationPolicyViewController.swift */,
);
path = "Notification Filtering";
sourceTree = "<group>";
};
D80F627E2B5C32E400877059 /* NotificationView */ = {
isa = PBXGroup;
children = (
@ -1820,6 +1851,27 @@
path = Documentation;
sourceTree = "<group>";
};
D85DF9702C481B1100A01408 /* Requests */ = {
isa = PBXGroup;
children = (
D85DF97C2C50EF8700A01408 /* Account Notifications */,
D85DF96E2C481B1100A01408 /* NotificationRequestsTableViewController.swift */,
D85DF96F2C481B1100A01408 /* NotificationRequestTableViewCell.swift */,
D85DF9752C4965A900A01408 /* NotificationRequestsViewModel.swift */,
D85DF9792C4E49A400A01408 /* NotificationRequestCountView.swift */,
);
name = Requests;
path = "Notification Filtering/Requests";
sourceTree = "<group>";
};
D85DF97C2C50EF8700A01408 /* Account Notifications */ = {
isa = PBXGroup;
children = (
D85DF97D2C50EFA700A01408 /* AccountNotificationTimelineViewController.swift */,
);
path = "Account Notifications";
sourceTree = "<group>";
};
D8A6AB68291C50F3003AB663 /* Login */ = {
isa = PBXGroup;
children = (
@ -2397,6 +2449,7 @@
DB697DDC278F521D004EF2F7 /* DataSourceFacade.swift */,
6213AF5D2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift */,
DB697DE0278F5296004EF2F7 /* DataSourceFacade+Model.swift */,
D85DF9732C481B3500A01408 /* DataSourceFacade+Notifications.swift */,
DB697DDE278F524F004EF2F7 /* DataSourceFacade+Profile.swift */,
DB8F7075279E954700E1225B /* DataSourceFacade+Follow.swift */,
DB603110279EB38500A935FE /* DataSourceFacade+Mute.swift */,
@ -2667,6 +2720,8 @@
DB9D6BFD25E4F57B0051B173 /* Notification */ = {
isa = PBXGroup;
children = (
D85DF9702C481B1100A01408 /* Requests */,
D80EC2602C2978CB009724A5 /* Notification Filtering */,
DB63F765279A5E5600455B82 /* NotificationTimeline */,
2D35237F26256F470031AF25 /* Cell */,
D80F627E2B5C32E400877059 /* NotificationView */,
@ -3497,6 +3552,7 @@
DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */,
DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */,
DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */,
D85DF9712C481B1100A01408 /* NotificationRequestsTableViewController.swift in Sources */,
D8FAAE432AD047B200DC1832 /* AboutInstanceTableFooterView.swift in Sources */,
D808B94E296EFBBA0031EB1E /* StatusEditHistoryTableViewCell.swift in Sources */,
D852C23E2AC5D03300309232 /* InstanceRulesViewController.swift in Sources */,
@ -3535,6 +3591,7 @@
DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */,
DB5B54A62833BE0000DEF8B2 /* UserListViewModel+State.swift in Sources */,
DB0617ED277F02C50030EE79 /* OnboardingNavigationController.swift in Sources */,
D85DF9722C481B1100A01408 /* NotificationRequestTableViewCell.swift in Sources */,
D808B94C296ECFDC0031EB1E /* StatusEditHistoryViewModel.swift in Sources */,
DB0617F527855AB90030EE79 /* ServerRuleSection.swift in Sources */,
DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */,
@ -3557,6 +3614,7 @@
DB6180DD263918E30018D199 /* MediaPreviewViewController.swift in Sources */,
DBE3CDEC261C6B2900430CC6 /* FavoriteViewController.swift in Sources */,
DB938EE62623F50700E5B6C1 /* ThreadViewController.swift in Sources */,
D85DF9762C4965A900A01408 /* NotificationRequestsViewModel.swift in Sources */,
DB6180F426391D110018D199 /* MediaPreviewImageView.swift in Sources */,
DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */,
2D939AB525EDD8A90076FA61 /* String.swift in Sources */,
@ -3581,6 +3639,7 @@
2D38F1EB25CD477000561493 /* HomeTimelineViewModel+LoadLatestState.swift in Sources */,
DB5B7295273112B100081888 /* FollowingListViewController.swift in Sources */,
0F202201261326E6000C64BF /* HashtagTimelineViewModel.swift in Sources */,
D85DF96C2C481AF700A01408 /* NotificationPolicyViewController.swift in Sources */,
D81A94172B07A1D30067A19D /* ProfileCardView+Configuration.swift in Sources */,
DB63F7452799056400455B82 /* HashtagTableViewCell.swift in Sources */,
D82BD7552ABC73AF009A374A /* NotificationPolicyTableViewCell.swift in Sources */,
@ -3601,6 +3660,7 @@
2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */,
DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */,
D8E5C349296DB8A3007E76A7 /* StatusEditHistoryViewController.swift in Sources */,
D85DF96B2C481AF700A01408 /* NotificationPolicyFilterTableViewCell.swift in Sources */,
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
D8318A882A4468D300C0FB73 /* NotificationSettingsViewController.swift in Sources */,
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */,
@ -3753,6 +3813,7 @@
DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */,
DB0FCB822796AC78006C02E2 /* UserTimelineViewController+DataSourceProvider.swift in Sources */,
DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */,
D85DF96D2C481AF700A01408 /* NotificationPolicyHeaderView.swift in Sources */,
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
2D7867192625B77500211898 /* NotificationItem.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
@ -3766,9 +3827,11 @@
DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */,
0F20220726134DA4000C64BF /* HashtagTimelineViewModel+Diffable.swift in Sources */,
DB7A9F932818F33C0016AF98 /* MastodonServerRulesViewController+Debug.swift in Sources */,
D85DF97E2C50EFA700A01408 /* AccountNotificationTimelineViewController.swift in Sources */,
2D5A3D2825CF8BC9002347D6 /* HomeTimelineViewModel+Diffable.swift in Sources */,
DB6B74FC272FF55800C70B6E /* UserSection.swift in Sources */,
DB0FCB862796BDA1006C02E2 /* SearchSection.swift in Sources */,
D83B54F82C2AC2FA00D18A7B /* NotificationFilteringBannerTableViewCell.swift in Sources */,
DB1D61CF26F1B33600DA8662 /* WelcomeViewModel.swift in Sources */,
D84738D42BBD9ABE00ECD52B /* TimelineStatusPill.swift in Sources */,
D8B5E4F42A4ED0240008970C /* NotificationSettingsViewModel.swift in Sources */,
@ -3777,6 +3840,7 @@
D852C23C2AC5D02C00309232 /* AboutInstanceViewController.swift in Sources */,
D8F917142A4D74C3008A5370 /* GeneralSettingsDiffableTableViewDataSource.swift in Sources */,
2A5242772C199EC2005B9E22 /* PrivacySafetySettingPreset.swift in Sources */,
D85DF97A2C4E49A400A01408 /* NotificationRequestCountView.swift in Sources */,
DB6180FA26391F2E0018D199 /* MediaPreviewViewModel.swift in Sources */,
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */,
DB6B7500272FF73800C70B6E /* UserTableViewCell.swift in Sources */,
@ -3854,6 +3918,7 @@
D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */,
DB0617FD27855BFE0030EE79 /* ServerRuleItem.swift in Sources */,
5BB04FD5262E7AFF0043BFF6 /* ReportViewController.swift in Sources */,
D85DF9742C481B3500A01408 /* DataSourceFacade+Notifications.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -205,7 +205,12 @@ extension SceneCoordinator {
// setting
case settings(setting: Setting)
// Notifications
case notificationPolicy(viewModel: NotificationFilterViewModel)
case notificationRequests(viewModel: NotificationRequestsViewModel)
case accountNotificationTimeline(viewModel: NotificationTimelineViewModel, request: Mastodon.Entity.NotificationRequest)
// report
case report(viewModel: ReportViewModel)
case reportServerRules(viewModel: ReportServerRulesViewModel)
@ -558,6 +563,12 @@ private extension SceneCoordinator {
case .editStatus(let viewModel):
let composeViewController = ComposeViewController(viewModel: viewModel)
viewController = composeViewController
case .notificationRequests(let viewModel):
viewController = NotificationRequestsTableViewController(viewModel: viewModel)
case .notificationPolicy(let viewModel):
viewController = NotificationPolicyViewController(viewModel: viewModel)
case .accountNotificationTimeline(let viewModel, let request):
viewController = AccountNotificationTimelineViewController(viewModel: viewModel, context: appContext, coordinator: self, notificationRequest: request)
}
setupDependency(for: viewController as? NeedsDependency)

View File

@ -0,0 +1,52 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import Foundation
import MastodonCore
import MastodonSDK
extension DataSourceFacade {
@MainActor
static func coordinateToNotificationRequests(
provider: DataSourceProvider & AuthContextProvider
) async {
provider.coordinator.showLoading()
do {
let notificationRequests = try await provider.context.apiService.notificationRequests(authenticationBox: provider.authContext.mastodonAuthenticationBox).value
let viewModel = NotificationRequestsViewModel(appContext: provider.context, authContext: provider.authContext, coordinator: provider.coordinator, requests: notificationRequests)
provider.coordinator.hideLoading()
let transition: SceneCoordinator.Transition
if provider.traitCollection.userInterfaceIdiom == .phone {
transition = .show
} else {
transition = .modal(animated: true)
}
provider.coordinator.present(scene: .notificationRequests(viewModel: viewModel), transition: transition)
} catch {
//TODO: Error Handling
provider.coordinator.hideLoading()
}
}
@MainActor
static func coordinateToNotificationRequest(
request: Mastodon.Entity.NotificationRequest,
provider: ViewControllerWithDependencies & AuthContextProvider
) async -> AccountNotificationTimelineViewController? {
provider.coordinator.showLoading()
let notificationTimelineViewModel = NotificationTimelineViewModel(context: provider.context, authContext: provider.authContext, scope: .fromAccount(request.account))
provider.coordinator.hideLoading()
guard let viewController = provider.coordinator.present(scene: .accountNotificationTimeline(viewModel: notificationTimelineViewModel, request: request), transition: .show) as? AccountNotificationTimelineViewController else { return nil }
return viewController
}
}

View File

@ -17,32 +17,30 @@ extension DataSourceFacade {
item: DataSourceItem
) async {
switch item {
case .account(account: let account, relationship: _):
let now = Date()
let userID = provider.authContext.mastodonAuthenticationBox.userID
let searchEntry = Persistence.SearchHistory.Item(
updatedAt: now,
userID: userID,
account: account,
hashtag: nil
)
case .account(account: let account, relationship: _):
let now = Date()
let userID = provider.authContext.mastodonAuthenticationBox.userID
let searchEntry = Persistence.SearchHistory.Item(
updatedAt: now,
userID: userID,
account: account,
hashtag: nil
)
try? FileManager.default.addSearchItem(searchEntry, for: provider.authContext.mastodonAuthenticationBox)
case .hashtag(let tag):
try? FileManager.default.addSearchItem(searchEntry, for: provider.authContext.mastodonAuthenticationBox)
case .hashtag(let tag):
let now = Date()
let userID = provider.authContext.mastodonAuthenticationBox.userID
let searchEntry = Persistence.SearchHistory.Item(
updatedAt: now,
userID: userID,
account: nil,
hashtag: tag
)
let now = Date()
let userID = provider.authContext.mastodonAuthenticationBox.userID
let searchEntry = Persistence.SearchHistory.Item(
updatedAt: now,
userID: userID,
account: nil,
hashtag: tag
)
try? FileManager.default.addSearchItem(searchEntry, for: provider.authContext.mastodonAuthenticationBox)
case .status:
break
case .notification:
try? FileManager.default.addSearchItem(searchEntry, for: provider.authContext.mastodonAuthenticationBox)
case .status, .notification, .notificationBanner(_):
break
}

View File

@ -514,10 +514,9 @@ extension NotificationTableViewCellDelegate where Self: DataSourceProvider & Aut
)
case .account(let account, _):
await DataSourceFacade.coordinateToProfileScene(provider: self, account: account)
case .notification:
assertionFailure("TODO")
case .hashtag(_):
assertionFailure("TODO")
case .notification, .hashtag(_), .notificationBanner(_):
// not supposed to happen
break
}
} // end Task
}

View File

@ -618,10 +618,9 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider & AuthConte
provider: self,
account: account
)
case .notification:
assertionFailure("TODO")
case .hashtag(_):
assertionFailure("TODO")
case .notification, .hashtag(_), .notificationBanner(_):
// not supposed to happen
break
}
}
}

View File

@ -22,42 +22,44 @@ extension UITableViewDelegate where Self: DataSourceProvider & AuthContextProvid
return
}
switch item {
case .account(let account, relationship: _):
await DataSourceFacade.coordinateToProfileScene(provider: self, account: account)
case .status(let status):
case .account(let account, relationship: _):
await DataSourceFacade.coordinateToProfileScene(provider: self, account: account)
case .status(let status):
await DataSourceFacade.coordinateToStatusThreadScene(
provider: self,
target: .status, // remove reblog wrapper
status: status
)
case .hashtag(let tag):
await DataSourceFacade.coordinateToHashtagScene(
provider: self,
tag: tag
)
case .notification(let notification):
let _status: MastodonStatus? = notification.status
if let status = _status {
await DataSourceFacade.coordinateToStatusThreadScene(
provider: self,
target: .status, // remove reblog wrapper
status: status
)
case .hashtag(let tag):
await DataSourceFacade.coordinateToHashtagScene(
provider: self,
tag: tag
} else if let accountWarning = notification.entity.accountWarning {
let url = Mastodon.API.disputesEndpoint(domain: authContext.mastodonAuthenticationBox.domain, strikeId: accountWarning.id)
_ = coordinator.present(
scene: .safari(url: url),
from: self,
transition: .safariPresent(animated: true, completion: nil)
)
case .notification(let notification):
let _status: MastodonStatus? = notification.status
if let status = _status {
await DataSourceFacade.coordinateToStatusThreadScene(
provider: self,
target: .status, // remove reblog wrapper
status: status
)
} else if let accountWarning = notification.entity.accountWarning {
let url = Mastodon.API.disputesEndpoint(domain: authContext.mastodonAuthenticationBox.domain, strikeId: accountWarning.id)
_ = coordinator.present(
scene: .safari(url: url),
from: self,
transition: .safariPresent(animated: true, completion: nil)
)
} else {
await DataSourceFacade.coordinateToProfileScene(
provider: self,
account: notification.entity.account
)
} // end Task
} // end func
} else {
await DataSourceFacade.coordinateToProfileScene(
provider: self,
account: notification.entity.account
)
}
case .notificationBanner(let policy):
await DataSourceFacade.coordinateToNotificationRequests(provider: self)
}
}
}
}

View File

@ -14,6 +14,7 @@ enum DataSourceItem: Hashable {
case status(record: MastodonStatus)
case hashtag(tag: Mastodon.Entity.Tag)
case notification(record: MastodonNotification)
case notificationBanner(policy: Mastodon.Entity.NotificationPolicy)
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
}

View File

@ -0,0 +1,88 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonSDK
import MastodonUI
import MastodonLocalization
class NotificationFilteringBannerTableViewCell: UITableViewCell {
static let reuseIdentifier = "NotificationFilteringBannerTableViewCell"
let iconImageView: UIImageView
let iconImageWrapperView: UIView
let titleLabel: UILabel
let subtitleLabel: UILabel
private let contentStackView: UIStackView
private let labelStackView: UIStackView
let separatorLine: UIView
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
let iconConfiguration = UIImage.SymbolConfiguration(scale: .large)
let icon = UIImage(systemName: "archivebox", withConfiguration: iconConfiguration)
iconImageView = UIImageView(image: icon)
iconImageView.translatesAutoresizingMaskIntoConstraints = false
iconImageWrapperView = UIView()
iconImageWrapperView.translatesAutoresizingMaskIntoConstraints = false
iconImageWrapperView.addSubview(iconImageView)
titleLabel = UILabel()
titleLabel.text = L10n.Scene.Notification.FilteredNotification.title
titleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
subtitleLabel = UILabel()
subtitleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
subtitleLabel.textColor = .secondaryLabel
labelStackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
labelStackView.translatesAutoresizingMaskIntoConstraints = false
labelStackView.alignment = .leading
labelStackView.axis = .vertical
contentStackView = UIStackView(arrangedSubviews: [iconImageWrapperView, labelStackView])
contentStackView.translatesAutoresizingMaskIntoConstraints = false
contentStackView.alignment = .center
contentStackView.axis = .horizontal
contentStackView.spacing = 12
separatorLine = UIView.separatorLine
separatorLine.translatesAutoresizingMaskIntoConstraints = false
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(contentStackView)
contentView.addSubview(separatorLine)
setupConstraints()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
iconImageWrapperView.widthAnchor.constraint(equalToConstant: CGSize.authorAvatarButtonSize.width),
iconImageWrapperView.heightAnchor.constraint(equalToConstant: CGSize.authorAvatarButtonSize.height).priority(.defaultHigh),
iconImageView.centerXAnchor.constraint(equalTo: iconImageWrapperView.centerXAnchor),
iconImageView.centerYAnchor.constraint(equalTo: iconImageWrapperView.centerYAnchor),
contentStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
contentStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
contentView.trailingAnchor.constraint(equalTo: contentStackView.trailingAnchor, constant: 16),
separatorLine.topAnchor.constraint(equalTo: contentStackView.bottomAnchor, constant: 7),
separatorLine.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
separatorLine.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
separatorLine.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
separatorLine.heightAnchor.constraint(equalToConstant: UIView.separatorLineHeight(of: contentView))
]
NSLayoutConstraint.activate(constraints)
}
func configure(with policy: Mastodon.Entity.NotificationPolicy) {
subtitleLabel.text = L10n.Plural.FilteredNotificationBanner.subtitle(policy.summary.pendingRequestsCount)
}
}

View File

@ -0,0 +1,54 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
protocol NotificationPolicyFilterTableViewCellDelegate: AnyObject {
func toggleValueChanged(_ tableViewCell: NotificationPolicyFilterTableViewCell, filterItem: NotificationFilterItem, newValue: Bool)
}
class NotificationPolicyFilterTableViewCell: ToggleTableViewCell {
override class var reuseIdentifier: String {
return "NotificationPolicyFilterTableViewCell"
}
var filterItem: NotificationFilterItem?
weak var delegate: NotificationPolicyFilterTableViewCellDelegate?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
subtitleLabel.textColor = .secondaryLabel
subtitleLabel.font = UIFontMetrics(forTextStyle: .subheadline).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
toggle.addTarget(self, action: #selector(NotificationPolicyFilterTableViewCell.toggleValueChanged(_:)), for: .valueChanged)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public func configure(with filterItem: NotificationFilterItem, viewModel: NotificationFilterViewModel) {
label.text = filterItem.title
subtitleLabel.text = filterItem.subtitle
self.filterItem = filterItem
let toggleIsOn: Bool
switch filterItem {
case .notFollowing:
toggleIsOn = viewModel.notFollowing
case .noFollower:
toggleIsOn = viewModel.noFollower
case .newAccount:
toggleIsOn = viewModel.newAccount
case .privateMentions:
toggleIsOn = viewModel.privateMentions
}
toggle.isOn = toggleIsOn
}
@objc func toggleValueChanged(_ sender: UISwitch) {
guard let filterItem, let delegate else { return }
delegate.toggleValueChanged(self, filterItem: filterItem, newValue: sender.isOn)
}
}

View File

@ -0,0 +1,53 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonLocalization
class NotificationPolicyHeaderView: UIView {
let titleLabel: UILabel
let closeButton: UIButton
override init(frame: CGRect) {
titleLabel = UILabel()
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.font = UIFontMetrics(forTextStyle: .title3).scaledFont(for: .systemFont(ofSize: 20, weight: .bold))
titleLabel.text = L10n.Scene.Notification.Policy.title
let buttonImageConfiguration = UIImage
.SymbolConfiguration(pointSize: 30)
.applying(UIImage.SymbolConfiguration(paletteColors: [.secondaryLabel, .quaternarySystemFill]))
let buttonImage = UIImage(systemName: "xmark.circle.fill", withConfiguration: buttonImageConfiguration)
var buttonConfiguration = UIButton.Configuration.plain()
buttonConfiguration.image = buttonImage
buttonConfiguration.contentInsets = .init(top: 0, leading: 10, bottom: 0, trailing: 0)
closeButton = UIButton(configuration: buttonConfiguration)
closeButton.translatesAutoresizingMaskIntoConstraints = false
closeButton.contentMode = .center
super.init(frame: frame)
addSubview(titleLabel)
addSubview(closeButton)
setupConstraints()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 10),
closeButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 8),
bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor),
closeButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor),
trailingAnchor.constraint(equalTo: closeButton.trailingAnchor, constant: 20),
bottomAnchor.constraint(greaterThanOrEqualTo: closeButton.bottomAnchor)
]
NSLayoutConstraint.activate(constraints)
}
}

View File

@ -0,0 +1,212 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonLocalization
import MastodonAsset
import MastodonCore
import MastodonSDK
enum NotificationFilterSection: Hashable {
case main
}
enum NotificationFilterItem: Hashable, CaseIterable {
case notFollowing
case noFollower
case newAccount
case privateMentions
var title: String {
switch self {
case .notFollowing:
return L10n.Scene.Notification.Policy.NotFollowing.title
case .noFollower:
return L10n.Scene.Notification.Policy.NoFollower.title
case .newAccount:
return L10n.Scene.Notification.Policy.NewAccount.title
case .privateMentions:
return L10n.Scene.Notification.Policy.PrivateMentions.title
}
}
var subtitle: String {
switch self {
case .notFollowing:
return L10n.Scene.Notification.Policy.NotFollowing.subtitle
case .noFollower:
return L10n.Scene.Notification.Policy.NoFollower.subtitle
case .newAccount:
return L10n.Scene.Notification.Policy.NewAccount.subtitle
case .privateMentions:
return L10n.Scene.Notification.Policy.PrivateMentions.subtitle
}
}
}
struct NotificationFilterViewModel {
var notFollowing: Bool
var noFollower: Bool
var newAccount: Bool
var privateMentions: Bool
let appContext: AppContext
init(appContext: AppContext, notFollowing: Bool, noFollower: Bool, newAccount: Bool, privateMentions: Bool) {
self.appContext = appContext
self.notFollowing = notFollowing
self.noFollower = noFollower
self.newAccount = newAccount
self.privateMentions = privateMentions
}
}
protocol NotificationPolicyViewControllerDelegate: AnyObject {
func policyUpdated(_ viewController: NotificationPolicyViewController, newPolicy: Mastodon.Entity.NotificationPolicy)
}
class NotificationPolicyViewController: UIViewController {
let tableView: UITableView
let headerBar: NotificationPolicyHeaderView
var saveItem: UIBarButtonItem?
var dataSource: UITableViewDiffableDataSource<NotificationFilterSection, NotificationFilterItem>?
let items: [NotificationFilterItem]
var viewModel: NotificationFilterViewModel
weak var delegate: NotificationPolicyViewControllerDelegate?
init(viewModel: NotificationFilterViewModel) {
self.viewModel = viewModel
items = NotificationFilterItem.allCases
headerBar = NotificationPolicyHeaderView()
headerBar.translatesAutoresizingMaskIntoConstraints = false
tableView = UITableView(frame: .zero, style: .insetGrouped)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.register(NotificationPolicyFilterTableViewCell.self, forCellReuseIdentifier: NotificationPolicyFilterTableViewCell.reuseIdentifier)
tableView.contentInset.top = -20
super.init(nibName: nil, bundle: nil)
let dataSource = UITableViewDiffableDataSource<NotificationFilterSection, NotificationFilterItem>(tableView: tableView) { [weak self] tableView, indexPath, itemIdentifier in
guard let self, let cell = tableView.dequeueReusableCell(withIdentifier: NotificationPolicyFilterTableViewCell.reuseIdentifier, for: indexPath) as? NotificationPolicyFilterTableViewCell else {
fatalError("No NotificationPolicyFilterTableViewCell")
}
let item = items[indexPath.row]
cell.configure(with: item, viewModel: self.viewModel)
cell.delegate = self
return cell
}
tableView.dataSource = dataSource
tableView.delegate = self
self.dataSource = dataSource
view.addSubview(headerBar)
view.addSubview(tableView)
view.backgroundColor = .systemGroupedBackground
headerBar.closeButton.addTarget(self, action: #selector(NotificationPolicyViewController.save(_:)), for: .touchUpInside)
setupConstraints()
}
override func viewDidLoad() {
super.viewDidLoad()
var snapshot = NSDiffableDataSourceSnapshot<NotificationFilterSection, NotificationFilterItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource?.apply(snapshot, animatingDifferences: false)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
headerBar.topAnchor.constraint(equalTo: view.topAnchor),
headerBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: headerBar.trailingAnchor),
tableView.topAnchor.constraint(equalTo: headerBar.bottomAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
view.bottomAnchor.constraint(equalTo: tableView.bottomAnchor),
]
NSLayoutConstraint.activate(constraints)
}
// MARK: - Action
@objc private func save(_ sender: UIButton) {
guard let authenticationBox = viewModel.appContext.authenticationService.mastodonAuthenticationBoxes.first else { return }
Task { [weak self] in
guard let self else { return }
do {
let updatedPolicy = try await viewModel.appContext.apiService.updateNotificationPolicy(
authenticationBox: authenticationBox,
filterNotFollowing: viewModel.notFollowing,
filterNotFollowers: viewModel.noFollower,
filterNewAccounts: viewModel.newAccount,
filterPrivateMentions: viewModel.privateMentions
).value
delegate?.policyUpdated(self, newPolicy: updatedPolicy)
NotificationCenter.default.post(name: .notificationFilteringChanged, object: nil)
} catch {}
}
dismiss(animated:true)
}
@objc private func cancel(_ sender: UIBarButtonItem) {
dismiss(animated: true)
}
}
//MARK: - UITableViewDelegate
extension NotificationPolicyViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let filterItem = items[indexPath.row]
switch filterItem {
case .notFollowing:
viewModel.notFollowing.toggle()
case .noFollower:
viewModel.noFollower.toggle()
case .newAccount:
viewModel.newAccount.toggle()
case .privateMentions:
viewModel.privateMentions.toggle()
}
if let snapshot = dataSource?.snapshot() {
dataSource?.applySnapshotUsingReloadData(snapshot)
}
}
}
extension NotificationPolicyViewController: NotificationPolicyFilterTableViewCellDelegate {
func toggleValueChanged(_ tableViewCell: NotificationPolicyFilterTableViewCell, filterItem: NotificationFilterItem, newValue: Bool) {
switch filterItem {
case .notFollowing:
viewModel.notFollowing = newValue
case .noFollower:
viewModel.noFollower = newValue
case .newAccount:
viewModel.newAccount = newValue
case .privateMentions:
viewModel.privateMentions = newValue
}
}
}

View File

@ -0,0 +1,52 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonCore
import MastodonSDK
import MastodonLocalization
protocol AccountNotificationTimelineViewControllerDelegate: AnyObject {
func acceptRequest(_ viewController: AccountNotificationTimelineViewController, request: Mastodon.Entity.NotificationRequest)
func dismissRequest(_ viewController: AccountNotificationTimelineViewController, request: Mastodon.Entity.NotificationRequest)
}
class AccountNotificationTimelineViewController: NotificationTimelineViewController {
let request: Mastodon.Entity.NotificationRequest
weak var delegate: AccountNotificationTimelineViewControllerDelegate?
init(viewModel: NotificationTimelineViewModel, context: AppContext, coordinator: SceneCoordinator, notificationRequest: Mastodon.Entity.NotificationRequest) {
self.request = notificationRequest
super.init(viewModel: viewModel, context: context, coordinator: coordinator)
navigationItem.rightBarButtonItem = UIBarButtonItem(title: nil, image: UIImage(systemName: "ellipsis.circle"), target: nil, action: nil, menu: menu())
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
// MARK: - Actions
func menu() -> UIMenu {
let menu = UIMenu(children: [
UIAction(title: L10n.Scene.Notification.FilteredNotification.accept, image: UIImage(systemName: "checkmark")) { [weak self] _ in
guard let self else { return }
coordinator.showLoading()
self.navigationController?.popViewController(animated: true)
self.delegate?.acceptRequest(self, request: request)
coordinator.hideLoading()
},
UIAction(title: L10n.Scene.Notification.FilteredNotification.dismiss, image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
guard let self else { return }
coordinator.showLoading()
self.navigationController?.popViewController(animated: true)
self.delegate?.dismissRequest(self, request: request)
coordinator.hideLoading()
}
])
return menu
}
}

View File

@ -0,0 +1,44 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonAsset
class NotificationRequestCountView: UIView {
let countLabel: UILabel
init() {
countLabel = UILabel()
countLabel.translatesAutoresizingMaskIntoConstraints = false
countLabel.textColor = .white
countLabel.font = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: .systemFont(ofSize: 13, weight: .regular))
countLabel.textAlignment = .center
super.init(frame: .zero)
addSubview(countLabel)
backgroundColor = Asset.Colors.Brand.blurple.color
layer.borderWidth = 2.0
layer.borderColor = UIColor.white.cgColor
applyCornerRadius(radius: 10)
setupConstraints()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
countLabel.topAnchor.constraint(equalTo: topAnchor, constant: 2),
countLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5),
trailingAnchor.constraint(equalTo: countLabel.trailingAnchor, constant: 5),
bottomAnchor.constraint(equalTo: countLabel.bottomAnchor, constant: 2),
widthAnchor.constraint(greaterThanOrEqualToConstant: 20),
heightAnchor.constraint(greaterThanOrEqualToConstant: 20)
]
NSLayoutConstraint.activate(constraints)
}
}

View File

@ -0,0 +1,215 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonSDK
import MetaTextKit
import MastodonMeta
import MastodonUI
import MastodonCore
import MastodonLocalization
import MastodonAsset
protocol NotificationRequestTableViewCellDelegate: AnyObject {
func acceptNotificationRequest(_ cell: NotificationRequestTableViewCell, notificationRequest: Mastodon.Entity.NotificationRequest)
func rejectNotificationRequest(_ cell: NotificationRequestTableViewCell, notificationRequest: Mastodon.Entity.NotificationRequest)
}
class NotificationRequestTableViewCell: UITableViewCell {
static let reuseIdentifier = "NotificationRequestTableViewCell"
var notificationRequest: Mastodon.Entity.NotificationRequest?
weak var delegate: NotificationRequestTableViewCellDelegate?
let nameLabel: MetaLabel
let usernameLabel: MetaLabel
let avatarButton: AvatarButton
let chevronImageView: UIImageView
private let labelStackView: UIStackView
private let avatarStackView: UIStackView
private let contentStackView: UIStackView
let acceptNotificationRequestButtonShadowBackgroundContainer = ShadowBackgroundContainer()
let acceptNotificationRequestButton: HighlightDimmableButton
let acceptNotificationRequestActivityIndicatorView: UIActivityIndicatorView
let rejectNotificationRequestButtonShadowBackgroundContainer = ShadowBackgroundContainer()
let rejectNotificationRequestActivityIndicatorView: UIActivityIndicatorView
let rejectNotificationRequestButton: HighlightDimmableButton
let requestCountView: NotificationRequestCountView
private let buttonStackView: UIStackView
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
nameLabel = MetaLabel(style: .statusName)
usernameLabel = MetaLabel(style: .statusUsername)
avatarButton = AvatarButton()
avatarButton.translatesAutoresizingMaskIntoConstraints = false
avatarButton.size = CGSize.authorAvatarButtonSize
avatarButton.avatarImageView.imageViewSize = CGSize.authorAvatarButtonSize
labelStackView = UIStackView(arrangedSubviews: [nameLabel, usernameLabel])
labelStackView.axis = .vertical
labelStackView.alignment = .leading
labelStackView.spacing = 4
acceptNotificationRequestButton = HighlightDimmableButton()
acceptNotificationRequestButton.translatesAutoresizingMaskIntoConstraints = false
acceptNotificationRequestButton.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
acceptNotificationRequestButton.setTitleColor(.white, for: .normal)
acceptNotificationRequestButton.setTitle(L10n.Scene.Notification.FilteredNotification.accept, for: .normal)
acceptNotificationRequestButton.setImage(UIImage(systemName: "checkmark"), for: .normal)
acceptNotificationRequestButton.imageView?.contentMode = .scaleAspectFit
acceptNotificationRequestButton.setBackgroundImage(.placeholder(color: Asset.Scene.Notification.confirmFollowRequestButtonBackground.color), for: .normal)
acceptNotificationRequestButton.setInsets(forContentPadding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8), imageTitlePadding: 8)
acceptNotificationRequestButton.tintColor = .white
acceptNotificationRequestButton.layer.masksToBounds = true
acceptNotificationRequestButton.layer.cornerCurve = .continuous
acceptNotificationRequestButton.layer.cornerRadius = 10
acceptNotificationRequestButton.accessibilityLabel = L10n.Scene.Notification.FollowRequest.accept
acceptNotificationRequestButtonShadowBackgroundContainer.cornerRadius = 10
acceptNotificationRequestButtonShadowBackgroundContainer.shadowAlpha = 0.1
acceptNotificationRequestButtonShadowBackgroundContainer.addSubview(acceptNotificationRequestButton)
acceptNotificationRequestActivityIndicatorView = UIActivityIndicatorView(style: .medium)
acceptNotificationRequestActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
acceptNotificationRequestActivityIndicatorView.color = .white
acceptNotificationRequestActivityIndicatorView.hidesWhenStopped = true
acceptNotificationRequestActivityIndicatorView.stopAnimating()
acceptNotificationRequestButton.addSubview(acceptNotificationRequestActivityIndicatorView)
rejectNotificationRequestButton = HighlightDimmableButton()
rejectNotificationRequestButton.translatesAutoresizingMaskIntoConstraints = false
rejectNotificationRequestButton.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
rejectNotificationRequestButton.setTitleColor(.black, for: .normal)
rejectNotificationRequestButton.setTitle(L10n.Scene.Notification.FilteredNotification.dismiss, for: .normal)
rejectNotificationRequestButton.setImage(UIImage(systemName: "speaker.slash"), for: .normal)
rejectNotificationRequestButton.imageView?.contentMode = .scaleAspectFit
rejectNotificationRequestButton.setInsets(forContentPadding: UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8), imageTitlePadding: 8)
rejectNotificationRequestButton.setBackgroundImage(.placeholder(color: Asset.Scene.Notification.deleteFollowRequestButtonBackground.color), for: .normal)
rejectNotificationRequestButton.tintColor = .black
rejectNotificationRequestButton.layer.masksToBounds = true
rejectNotificationRequestButton.layer.cornerCurve = .continuous
rejectNotificationRequestButton.layer.cornerRadius = 10
rejectNotificationRequestButton.accessibilityLabel = L10n.Scene.Notification.FollowRequest.reject
rejectNotificationRequestButtonShadowBackgroundContainer.cornerRadius = 10
rejectNotificationRequestButtonShadowBackgroundContainer.shadowAlpha = 0.1
rejectNotificationRequestButtonShadowBackgroundContainer.addSubview(rejectNotificationRequestButton)
rejectNotificationRequestActivityIndicatorView = UIActivityIndicatorView(style: .medium)
rejectNotificationRequestActivityIndicatorView.translatesAutoresizingMaskIntoConstraints = false
rejectNotificationRequestActivityIndicatorView.color = .black
rejectNotificationRequestActivityIndicatorView.hidesWhenStopped = true
rejectNotificationRequestActivityIndicatorView.stopAnimating()
rejectNotificationRequestButton.addSubview(rejectNotificationRequestActivityIndicatorView)
buttonStackView = UIStackView(arrangedSubviews: [rejectNotificationRequestButtonShadowBackgroundContainer, acceptNotificationRequestButtonShadowBackgroundContainer])
buttonStackView.axis = .horizontal
buttonStackView.distribution = .fillEqually
buttonStackView.spacing = 16
buttonStackView.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0) // set bottom padding
chevronImageView = UIImageView(image: UIImage(systemName: "chevron.right"))
chevronImageView.tintColor = .tertiaryLabel
avatarStackView = UIStackView(arrangedSubviews: [avatarButton, labelStackView, UIView(), chevronImageView])
avatarStackView.axis = .horizontal
avatarStackView.alignment = .center
avatarStackView.spacing = 12
contentStackView = UIStackView(arrangedSubviews: [avatarStackView, buttonStackView])
contentStackView.translatesAutoresizingMaskIntoConstraints = false
contentStackView.spacing = 16
contentStackView.axis = .vertical
contentStackView.alignment = .leading
requestCountView = NotificationRequestCountView()
requestCountView.translatesAutoresizingMaskIntoConstraints = false
super.init(style: style, reuseIdentifier: reuseIdentifier)
acceptNotificationRequestButton.addTarget(self, action: #selector(NotificationRequestTableViewCell.acceptNotificationRequest(_:)), for: .touchUpInside)
rejectNotificationRequestButton.addTarget(self, action: #selector(NotificationRequestTableViewCell.rejectNotificationRequest(_:)), for: .touchUpInside)
contentView.addSubview(contentStackView)
contentView.addSubview(requestCountView)
setupConstraints()
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
private func setupConstraints() {
let constraints = [
contentStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
contentStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
contentView.trailingAnchor.constraint(equalTo: contentStackView.trailingAnchor, constant: 16),
contentView.bottomAnchor.constraint(equalTo: contentStackView.bottomAnchor, constant: 16),
buttonStackView.widthAnchor.constraint(equalTo: contentStackView.widthAnchor),
avatarStackView.widthAnchor.constraint(equalTo: contentStackView.widthAnchor),
avatarButton.widthAnchor.constraint(equalToConstant: CGSize.authorAvatarButtonSize.width).priority(.required - 1),
avatarButton.heightAnchor.constraint(equalToConstant: CGSize.authorAvatarButtonSize.height).priority(.required - 1),
acceptNotificationRequestActivityIndicatorView.centerXAnchor.constraint(equalTo: acceptNotificationRequestButton.centerXAnchor),
acceptNotificationRequestActivityIndicatorView.centerYAnchor.constraint(equalTo: acceptNotificationRequestButton.centerYAnchor),
rejectNotificationRequestActivityIndicatorView.centerXAnchor.constraint(equalTo: rejectNotificationRequestButton.centerXAnchor),
rejectNotificationRequestActivityIndicatorView.centerYAnchor.constraint(equalTo: rejectNotificationRequestButton.centerYAnchor),
requestCountView.trailingAnchor.constraint(equalTo: avatarButton.trailingAnchor, constant: 2),
requestCountView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor, constant: 2),
]
NSLayoutConstraint.activate(constraints)
acceptNotificationRequestButton.pinToParent()
rejectNotificationRequestButton.pinToParent()
}
override func prepareForReuse() {
avatarButton.avatarImageView.image = nil
avatarButton.avatarImageView.cancelTask()
}
func configure(with request: Mastodon.Entity.NotificationRequest) {
let account = request.account
avatarButton.avatarImageView.configure(with: account.avatarImageURL())
avatarButton.avatarImageView.configure(cornerConfiguration: .init(corner: .fixed(radius: 12)))
// author name
let metaAccountName: MetaContent
do {
let content = MastodonContent(content: account.displayNameWithFallback, emojis: account.emojis.asDictionary)
metaAccountName = try MastodonMetaContent.convert(document: content)
} catch {
assertionFailure(error.localizedDescription)
metaAccountName = PlaintextMetaContent(string: account.displayNameWithFallback)
}
nameLabel.configure(content: metaAccountName)
let metaUsername = PlaintextMetaContent(string: "@\(account.acct)")
usernameLabel.configure(content: metaUsername)
requestCountView.countLabel.text = request.notificationsCount
requestCountView.setNeedsLayout()
requestCountView.layoutIfNeeded()
self.notificationRequest = request
}
// MARK: - Actions
@objc private func acceptNotificationRequest(_ sender: UIButton) {
guard let notificationRequest, let delegate else { return }
delegate.acceptNotificationRequest(self, notificationRequest: notificationRequest)
}
@objc private func rejectNotificationRequest(_ sender: UIButton) {
guard let notificationRequest, let delegate else { return }
delegate.rejectNotificationRequest(self, notificationRequest: notificationRequest)
}
}

View File

@ -0,0 +1,222 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import UIKit
import MastodonSDK
import MastodonCore
import MastodonAsset
import MastodonLocalization
enum NotificationRequestsSection: Hashable {
case main
}
enum NotificationRequestItem: Hashable {
case item(Mastodon.Entity.NotificationRequest)
}
protocol NotificationRequestsTableViewControllerDelegate: AnyObject {
func notificationRequestsUpdated(_ viewController: NotificationRequestsTableViewController)
}
class NotificationRequestsTableViewController: UIViewController, NeedsDependency {
var context: AppContext!
var coordinator: SceneCoordinator!
weak var delegate: NotificationRequestsTableViewControllerDelegate?
let tableView: UITableView
var viewModel: NotificationRequestsViewModel
var dataSource: UITableViewDiffableDataSource<NotificationRequestsSection, NotificationRequestItem>?
init(viewModel: NotificationRequestsViewModel) {
self.viewModel = viewModel
self.context = viewModel.appContext
self.coordinator = viewModel.coordinator
tableView = UITableView(frame: .zero)
tableView.translatesAutoresizingMaskIntoConstraints = false
tableView.backgroundColor = .secondarySystemBackground
tableView.register(NotificationRequestTableViewCell.self, forCellReuseIdentifier: NotificationRequestTableViewCell.reuseIdentifier)
super.init(nibName: nil, bundle: nil)
view.addSubview(tableView)
tableView.pinToParent()
let dataSource = UITableViewDiffableDataSource<NotificationRequestsSection, NotificationRequestItem>(tableView: tableView) { tableView, indexPath, itemIdentifier in
guard let cell = tableView.dequeueReusableCell(withIdentifier: NotificationRequestTableViewCell.reuseIdentifier, for: indexPath) as? NotificationRequestTableViewCell else {
fatalError("No NotificationRequestTableViewCell")
}
let request = viewModel.requests[indexPath.row]
cell.configure(with: request)
cell.delegate = self
return cell
}
tableView.dataSource = dataSource
tableView.delegate = self
self.dataSource = dataSource
title = L10n.Scene.Notification.FilteredNotification.title
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
var snapshot = NSDiffableDataSourceSnapshot<NotificationRequestsSection, NotificationRequestItem>()
snapshot.appendSections([.main])
snapshot.appendItems(viewModel.requests.compactMap { NotificationRequestItem.item($0) } )
dataSource?.apply(snapshot)
}
}
// MARK: - UITableViewDelegate
extension NotificationRequestsTableViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let request = viewModel.requests[indexPath.row]
Task { [weak self] in
guard let self else { return }
let viewController = await DataSourceFacade.coordinateToNotificationRequest(request: request, provider: self)
viewController?.delegate = self
}
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let dismissAction = UIContextualAction(style: .normal, title: "Dismiss") { [weak self] action, view, completion in
guard let request = self?.viewModel.requests[indexPath.row], let cell = tableView.cellForRow(at: indexPath) as? NotificationRequestTableViewCell else { return completion(false) }
self?.rejectNotificationRequest(cell, notificationRequest: request)
completion(true)
}
dismissAction.image = UIImage(systemName: "speaker.slash")
let swipeAction = UISwipeActionsConfiguration(actions: [dismissAction])
swipeAction.performsFirstActionWithFullSwipe = true
return swipeAction
}
}
// MARK: - AuthContextProvider
extension NotificationRequestsTableViewController: AuthContextProvider {
var authContext: AuthContext { viewModel.authContext }
}
extension NotificationRequestsTableViewController: NotificationRequestTableViewCellDelegate {
func acceptNotificationRequest(_ cell: NotificationRequestTableViewCell, notificationRequest: MastodonSDK.Mastodon.Entity.NotificationRequest) {
cell.acceptNotificationRequestActivityIndicatorView.isHidden = false
cell.acceptNotificationRequestActivityIndicatorView.startAnimating()
cell.acceptNotificationRequestButton.tintColor = .clear
cell.acceptNotificationRequestButton.setTitleColor(.clear, for: .normal)
cell.rejectNotificationRequestButton.isUserInteractionEnabled = false
cell.acceptNotificationRequestButton.isUserInteractionEnabled = false
Task { [weak self] in
guard let self else { return }
do {
try await acceptNotificationRequest(notificationRequest)
} catch {
cell.acceptNotificationRequestActivityIndicatorView.stopAnimating()
cell.acceptNotificationRequestButton.tintColor = .white
cell.acceptNotificationRequestButton.setTitleColor(.white, for: .normal)
cell.rejectNotificationRequestButton.isUserInteractionEnabled = true
cell.acceptNotificationRequestButton.isUserInteractionEnabled = true
}
}
}
private func acceptNotificationRequest(_ notificationRequest: MastodonSDK.Mastodon.Entity.NotificationRequest) async throws {
_ = try await context.apiService.acceptNotificationRequests(authenticationBox: authContext.mastodonAuthenticationBox,
id: notificationRequest.id)
let requests = try await context.apiService.notificationRequests(authenticationBox: authContext.mastodonAuthenticationBox).value
NotificationCenter.default.post(name: .notificationFilteringChanged, object: nil)
await MainActor.run { [weak self] in
guard let self else { return }
if requests.count > 0 {
self.viewModel.requests = requests
var snapshot = NSDiffableDataSourceSnapshot<NotificationRequestsSection, NotificationRequestItem>()
snapshot.appendSections([.main])
snapshot.appendItems(self.viewModel.requests.compactMap { NotificationRequestItem.item($0) } )
self.dataSource?.apply(snapshot)
} else {
_ = self.navigationController?.popViewController(animated: true)
}
}
}
func rejectNotificationRequest(_ cell: NotificationRequestTableViewCell, notificationRequest: MastodonSDK.Mastodon.Entity.NotificationRequest) {
cell.rejectNotificationRequestActivityIndicatorView.isHidden = false
cell.rejectNotificationRequestActivityIndicatorView.startAnimating()
cell.rejectNotificationRequestButton.tintColor = .clear
cell.rejectNotificationRequestButton.setTitleColor(.clear, for: .normal)
cell.rejectNotificationRequestButton.isUserInteractionEnabled = false
cell.acceptNotificationRequestButton.isUserInteractionEnabled = false
Task { [weak self] in
guard let self else { return }
do {
try await rejectNotificationRequest(notificationRequest)
} catch {
cell.rejectNotificationRequestActivityIndicatorView.stopAnimating()
cell.rejectNotificationRequestButton.tintColor = .black
cell.rejectNotificationRequestButton.setTitleColor(.black, for: .normal)
cell.rejectNotificationRequestButton.isUserInteractionEnabled = true
cell.acceptNotificationRequestButton.isUserInteractionEnabled = true
}
}
}
private func rejectNotificationRequest(_ notificationRequest: MastodonSDK.Mastodon.Entity.NotificationRequest) async throws {
_ = try await context.apiService.rejectNotificationRequests(authenticationBox: authContext.mastodonAuthenticationBox,
id: notificationRequest.id)
let requests = try await context.apiService.notificationRequests(authenticationBox: authContext.mastodonAuthenticationBox).value
NotificationCenter.default.post(name: .notificationFilteringChanged, object: nil)
await MainActor.run { [weak self] in
guard let self else { return }
if requests.count > 0 {
self.viewModel.requests = requests
var snapshot = NSDiffableDataSourceSnapshot<NotificationRequestsSection, NotificationRequestItem>()
snapshot.appendSections([.main])
snapshot.appendItems(self.viewModel.requests.compactMap { NotificationRequestItem.item($0) } )
self.dataSource?.apply(snapshot)
} else {
_ = self.navigationController?.popViewController(animated: true)
}
}
}
}
extension NotificationRequestsTableViewController: AccountNotificationTimelineViewControllerDelegate {
func acceptRequest(_ viewController: AccountNotificationTimelineViewController, request: MastodonSDK.Mastodon.Entity.NotificationRequest) {
Task {
try? await acceptNotificationRequest(request)
}
}
func dismissRequest(_ viewController: AccountNotificationTimelineViewController, request: MastodonSDK.Mastodon.Entity.NotificationRequest) {
Task {
try? await rejectNotificationRequest(request)
}
}
}

View File

@ -0,0 +1,20 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import Foundation
import MastodonSDK
import MastodonCore
struct NotificationRequestsViewModel {
let appContext: AppContext
let authContext: AuthContext
let coordinator: SceneCoordinator
var requests: [Mastodon.Entity.NotificationRequest]
init(appContext: AppContext, authContext: AuthContext, coordinator: SceneCoordinator, requests: [Mastodon.Entity.NotificationRequest]) {
self.appContext = appContext
self.authContext = authContext
self.coordinator = coordinator
self.requests = requests
}
}

View File

@ -10,6 +10,7 @@ import Foundation
import MastodonSDK
enum NotificationItem: Hashable {
case filteredNotifications(policy: Mastodon.Entity.NotificationPolicy)
case feed(record: MastodonFeed)
case feedLoader(record: MastodonFeed)
case bottomLoader

View File

@ -39,6 +39,7 @@ extension NotificationSection {
tableView.register(NotificationTableViewCell.self, forCellReuseIdentifier: String(describing: NotificationTableViewCell.self))
tableView.register(AccountWarningNotificationCell.self, forCellReuseIdentifier: AccountWarningNotificationCell.reuseIdentifier)
tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self))
tableView.register(NotificationFilteringBannerTableViewCell.self, forCellReuseIdentifier: NotificationFilteringBannerTableViewCell.reuseIdentifier)
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
@ -67,6 +68,12 @@ extension NotificationSection {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
case .filteredNotifications(let policy):
let cell = tableView.dequeueReusableCell(withIdentifier: NotificationFilteringBannerTableViewCell.reuseIdentifier, for: indexPath) as! NotificationFilteringBannerTableViewCell
cell.configure(with: policy)
return cell
}
}
}

View File

@ -33,7 +33,9 @@ extension NotificationTimelineViewController: DataSourceProvider {
}
}()
return item
default:
case .filteredNotifications(let policy):
return DataSourceItem.notificationBanner(policy: policy)
case .bottomLoader, .feedLoader(_):
return nil
}
}

View File

@ -11,18 +11,18 @@ import CoreDataStack
import MastodonCore
import MastodonLocalization
final class NotificationTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
class NotificationTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
weak var context: AppContext!
weak var coordinator: SceneCoordinator!
let mediaPreviewTransitionController = MediaPreviewTransitionController()
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
var viewModel: NotificationTimelineViewModel!
let viewModel: NotificationTimelineViewModel
private(set) lazy var refreshControl: RefreshControl = {
let refreshControl = RefreshControl()
refreshControl.addTarget(self, action: #selector(NotificationTimelineViewController.refreshControlValueChanged(_:)), for: .valueChanged)
@ -31,13 +31,26 @@ final class NotificationTimelineViewController: UIViewController, NeedsDependenc
private(set) lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.backgroundColor = .clear
tableView.backgroundColor = .secondarySystemBackground
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
return tableView
}()
let cellFrameCache = NSCache<NSNumber, NSValue>()
init(viewModel: NotificationTimelineViewModel, context: AppContext, coordinator: SceneCoordinator) {
self.viewModel = viewModel
self.context = context
self.coordinator = coordinator
super.init(nibName: nil, bundle: nil)
title = viewModel.scope.title
view.backgroundColor = .secondarySystemBackground
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
extension NotificationTimelineViewController {
@ -113,6 +126,9 @@ extension NotificationTimelineViewController {
@objc private func refreshControlValueChanged(_ sender: RefreshControl) {
Task {
let policy = try? await context.apiService.notificationPolicy(authenticationBox: authContext.mastodonAuthenticationBox)
viewModel.notificationPolicy = policy?.value
await viewModel.loadLatest()
}
}

View File

@ -33,7 +33,7 @@ extension NotificationTimelineViewModel {
dataController.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
guard let self = self else { return }
guard let self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
Task {
@ -44,6 +44,9 @@ extension NotificationTimelineViewModel {
}
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
snapshot.appendSections([.main])
if self.scope == .everything, let notificationPolicy = self.notificationPolicy, notificationPolicy.summary.pendingRequestsCount > 0 {
snapshot.appendItems([.filteredNotifications(policy: notificationPolicy)])
}
snapshot.appendItems(newItems.removingDuplicates(), toSection: .main)
return snapshot
}()

View File

@ -9,6 +9,7 @@ import CoreDataStack
import Foundation
import GameplayKit
import MastodonSDK
import MastodonCore
extension NotificationTimelineViewModel {
class LoadOldestState: GKState {
@ -51,8 +52,22 @@ extension NotificationTimelineViewModel.LoadOldestState {
stateMachine.enter(Fail.self)
return
}
let scope = viewModel.scope
let scope: APIService.MastodonNotificationScope?
let accountID: String?
switch viewModel.scope {
case .everything:
scope = .everything
accountID = nil
case .mentions:
scope = .mentions
accountID = nil
case .fromAccount(let account):
scope = nil
accountID = account.id
}
Task {
let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id
@ -64,6 +79,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
do {
let response = try await viewModel.context.apiService.notifications(
maxID: maxID,
accountID: accountID,
scope: scope,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)

View File

@ -11,6 +11,7 @@ import CoreDataStack
import GameplayKit
import MastodonSDK
import MastodonCore
import MastodonLocalization
final class NotificationTimelineViewModel {
@ -20,6 +21,7 @@ final class NotificationTimelineViewModel {
let context: AppContext
let authContext: AuthContext
let scope: Scope
var notificationPolicy: Mastodon.Entity.NotificationPolicy?
let dataController: FeedDataController
@Published var isLoadingLatest = false
@Published var lastAutomaticFetchTimestamp: Date?
@ -46,12 +48,14 @@ final class NotificationTimelineViewModel {
init(
context: AppContext,
authContext: AuthContext,
scope: Scope
scope: Scope,
notificationPolicy: Mastodon.Entity.NotificationPolicy? = nil
) {
self.context = context
self.authContext = authContext
self.scope = scope
self.dataController = FeedDataController(context: context, authContext: authContext)
self.notificationPolicy = notificationPolicy
switch scope {
case .everything:
@ -62,6 +66,8 @@ final class NotificationTimelineViewModel {
self.dataController.records = (try? FileManager.default.cachedNotificationsMentions(for: authContext.mastodonAuthenticationBox))?.map({ notification in
MastodonFeed.fromNotification(notification, relationship: nil, kind: .notificationMentions)
}) ?? []
case .fromAccount(_):
self.dataController.records = []
}
self.dataController.$records
@ -77,18 +83,47 @@ final class NotificationTimelineViewModel {
FileManager.default.cacheNotificationsAll(items: items, for: authContext.mastodonAuthenticationBox)
case .mentions:
FileManager.default.cacheNotificationsMentions(items: items, for: authContext.mastodonAuthenticationBox)
case .fromAccount(_):
//NOTE: we don't persist these
break
}
})
.store(in: &disposeBag)
NotificationCenter.default.addObserver(self, selector: #selector(Self.notificationFilteringChanged(_:)), name: .notificationFilteringChanged, object: nil)
}
//MARK: - Notifications
@objc func notificationFilteringChanged(_ notification: Notification) {
Task { [weak self] in
guard let self else { return }
let policy = try await self.context.apiService.notificationPolicy(authenticationBox: self.authContext.mastodonAuthenticationBox)
self.notificationPolicy = policy.value
await self.loadLatest()
}
}
}
extension NotificationTimelineViewModel {
enum Scope: Hashable {
case everything
case mentions
case fromAccount(Mastodon.Entity.Account)
typealias Scope = APIService.MastodonNotificationScope
var title: String {
switch self {
case .everything:
return L10n.Scene.Notification.Title.everything
case .mentions:
return L10n.Scene.Notification.Title.mentions
case .fromAccount(let account):
return "Notifications from \(account.displayName)"
}
}
}
}
extension NotificationTimelineViewModel {
@ -103,6 +138,8 @@ extension NotificationTimelineViewModel {
dataController.loadInitial(kind: .notificationAll)
case .mentions:
dataController.loadInitial(kind: .notificationMentions)
case .fromAccount(let account):
dataController.loadInitial(kind: .notificationAccount(account.id))
}
didLoadLatest.send()
@ -115,6 +152,8 @@ extension NotificationTimelineViewModel {
dataController.loadNext(kind: .notificationAll)
case .mentions:
dataController.loadNext(kind: .notificationMentions)
case .fromAccount(let account):
dataController.loadNext(kind: .notificationAccount(account.id))
}
}
}

View File

@ -39,9 +39,9 @@ extension NotificationView {
switch notification.entity.type {
case .follow:
setAuthorContainerBottomPaddingViewDisplay()
setAuthorContainerBottomPaddingViewDisplay(isHidden: true)
case .followRequest:
setFollowRequestAdaptiveMarginContainerViewDisplay()
setFollowRequestAdaptiveMarginContainerViewDisplay(isHidden: true)
case .mention, .status:
if let status = notification.status {
statusView.configure(status: status)

View File

@ -210,7 +210,7 @@ extension NotificationView {
containerStackView.topAnchor.constraint(equalTo: topAnchor),
containerStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
containerStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor),
bottomAnchor.constraint(equalTo: containerStackView.bottomAnchor, constant: 8),
])
// author container: H - [ avatarButton | author meta container ]
@ -327,7 +327,7 @@ extension NotificationView {
rejectFollowRequestActivityIndicatorView.centerYAnchor.constraint(equalTo: rejectFollowRequestButton.centerYAnchor),
])
rejectFollowRequestActivityIndicatorView.color = .black
acceptFollowRequestActivityIndicatorView.hidesWhenStopped = true
rejectFollowRequestActivityIndicatorView.hidesWhenStopped = true
rejectFollowRequestActivityIndicatorView.stopAnimating()
// statusView
@ -420,12 +420,12 @@ extension NotificationView {
extension NotificationView {
public func setAuthorContainerBottomPaddingViewDisplay() {
authorContainerViewBottomPaddingView.isHidden = false
public func setAuthorContainerBottomPaddingViewDisplay(isHidden: Bool = false) {
authorContainerViewBottomPaddingView.isHidden = isHidden
}
public func setFollowRequestAdaptiveMarginContainerViewDisplay() {
followRequestAdaptiveMarginContainerView.isHidden = false
public func setFollowRequestAdaptiveMarginContainerViewDisplay(isHidden: Bool = false) {
followRequestAdaptiveMarginContainerView.isHidden = isHidden
}
public func setStatusViewDisplay() {

View File

@ -12,6 +12,7 @@ import MastodonLocalization
import Tabman
import Pageboy
import MastodonCore
import MastodonSDK
final class NotificationViewController: TabmanViewController, NeedsDependency {
@ -49,7 +50,7 @@ extension NotificationViewController {
view.backgroundColor = .secondarySystemBackground
setupSegmentedControl(scopes: APIService.MastodonNotificationScope.allCases)
setupSegmentedControl(scopes: [.everything, .mentions])
pageSegmentedControl.translatesAutoresizingMaskIntoConstraints = false
navigationItem.titleView = pageSegmentedControl
NSLayoutConstraint.activate([
@ -68,7 +69,7 @@ extension NotificationViewController {
}
.store(in: &disposeBag)
viewModel?.viewControllers = APIService.MastodonNotificationScope.allCases.map { scope in
viewModel?.viewControllers = [NotificationTimelineViewModel.Scope.everything, .mentions].map { scope in
createViewController(for: scope)
}
@ -86,14 +87,11 @@ extension NotificationViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// aspectViewWillAppear(animated)
// fetch latest notification when scroll position is within half screen height to prevent list reload
// if tableView.contentOffset.y < view.frame.height * 0.5 {
// viewModel.loadLatestStateMachine.enter(NotificationViewModel.LoadLatestState.Loading.self)
// }
// https://github.com/mastodon/documentation/pull/1447#issuecomment-2149225659
if let viewModel, viewModel.notificationPolicy != nil {
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "line.3.horizontal.decrease.circle"), style: .plain, target: self, action: #selector(NotificationViewController.showNotificationPolicySettings(_:)))
}
// needs trigger manually after onboarding dismiss
setNeedsStatusBarAppearanceUpdate()
}
@ -117,6 +115,24 @@ extension NotificationViewController {
// aspectViewDidDisappear(animated)
}
//MARK: - Actions
@objc private func showNotificationPolicySettings(_ sender: Any) {
guard let viewModel, let policy = viewModel.notificationPolicy else { return }
let policyViewModel = NotificationFilterViewModel(
appContext: viewModel.context,
notFollowing: policy.filterNotFollowing,
noFollower: policy.filterNotFollowers,
newAccount: policy.filterNewAccounts,
privateMentions: policy.filterPrivateMentions
)
guard let policyViewController = coordinator.present(scene: .notificationPolicy(viewModel: policyViewModel), transition: .formSheet) as? NotificationPolicyViewController else { return }
policyViewController.delegate = self
}
}
extension NotificationViewController {
@ -134,17 +150,20 @@ extension NotificationViewController {
pageSegmentedControl.selectedSegmentIndex = 0
}
}
private func createViewController(for scope: NotificationTimelineViewModel.Scope) -> UIViewController {
guard let authContext = viewModel?.authContext else { return UITableViewController() }
let viewController = NotificationTimelineViewController()
viewController.context = context
viewController.coordinator = coordinator
viewController.viewModel = NotificationTimelineViewModel(
guard let viewModel else { return UITableViewController() }
let viewController = NotificationTimelineViewController(
viewModel: NotificationTimelineViewModel(
context: context,
authContext: viewModel.authContext,
scope: scope, notificationPolicy: viewModel.notificationPolicy
),
context: context,
authContext: authContext,
scope: scope
coordinator: coordinator
)
return viewController
}
}
@ -234,3 +253,12 @@ extension NotificationViewController {
return categorySwitchKeyCommands
}
}
//MARK: - NotificationPolicyViewControllerDelegate
extension NotificationViewController: NotificationPolicyViewControllerDelegate {
func policyUpdated(_ viewController: NotificationPolicyViewController, newPolicy: Mastodon.Entity.NotificationPolicy) {
viewModel?.notificationPolicy = newPolicy
}
}

View File

@ -11,6 +11,7 @@ import Pageboy
import MastodonAsset
import MastodonCore
import MastodonLocalization
import MastodonSDK
final class NotificationViewModel {
@ -19,6 +20,7 @@ final class NotificationViewModel {
// input
let context: AppContext
let authContext: AuthContext
var notificationPolicy: Mastodon.Entity.NotificationPolicy?
let viewDidLoad = PassthroughSubject<Void, Never>()
// output
@ -50,17 +52,15 @@ final class NotificationViewModel {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
// end init
}
}
extension NotificationTimelineViewModel.Scope {
var title: String {
switch self {
case .everything:
return L10n.Scene.Notification.Title.everything
case .mentions:
return L10n.Scene.Notification.Title.mentions
Task {
do {
let policy = try await context.apiService.notificationPolicy(authenticationBox: authContext.mastodonAuthenticationBox)
self.notificationPolicy = policy.value
} catch {
// we won't show the filtering-options.
}
}
}
}

View File

@ -70,7 +70,7 @@ extension SearchResultViewController {
provider: self,
tag: tag
)
case .notification:
case .notification, .notificationBanner(_):
assertionFailure()
} // end switch

View File

@ -17,6 +17,7 @@ class GeneralSettingToggleTableViewCell: ToggleTableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
subtitleLabel.isHidden = true
toggle.addTarget(self, action: #selector(GeneralSettingToggleTableViewCell.toggleValueChanged(_:)), for: .valueChanged)
}

View File

@ -18,6 +18,7 @@ class NotificationSettingTableViewToggleCell: ToggleTableViewCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
subtitleLabel.isHidden = true
toggle.addTarget(self, action: #selector(NotificationSettingTableViewToggleCell.toggleValueChanged(_:)), for: .valueChanged)
}

View File

@ -9,22 +9,33 @@ class ToggleTableViewCell: UITableViewCell {
}
let label: UILabel
let subtitleLabel: UILabel
private let labelStackView: UIStackView
let toggle: UISwitch
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .regular))
label.numberOfLines = 0
subtitleLabel = UILabel()
subtitleLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))
subtitleLabel.numberOfLines = 0
labelStackView = UIStackView(arrangedSubviews: [label, subtitleLabel])
labelStackView.translatesAutoresizingMaskIntoConstraints = false
labelStackView.alignment = .leading
labelStackView.axis = .vertical
labelStackView.spacing = 4
toggle = UISwitch()
toggle.translatesAutoresizingMaskIntoConstraints = false
toggle.onTintColor = Asset.Colors.Brand.blurple.color
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(label)
contentView.addSubview(labelStackView)
contentView.addSubview(toggle)
setupConstraints()
}
@ -33,11 +44,11 @@ class ToggleTableViewCell: UITableViewCell {
private func setupConstraints() {
let constraints = [
label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11),
label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
contentView.bottomAnchor.constraint(equalTo: label.bottomAnchor, constant: 11),
toggle.leadingAnchor.constraint(greaterThanOrEqualTo: label.trailingAnchor, constant: 16),
labelStackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11),
labelStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
contentView.bottomAnchor.constraint(equalTo: labelStackView.bottomAnchor, constant: 11),
toggle.leadingAnchor.constraint(greaterThanOrEqualTo: labelStackView.trailingAnchor, constant: 16),
toggle.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
contentView.trailingAnchor.constraint(equalTo: toggle.trailingAnchor, constant: 16)

View File

@ -202,13 +202,14 @@ private extension FeedDataController {
return try await getFeeds(with: .everything)
case .notificationMentions:
return try await getFeeds(with: .mentions)
case .notificationAccount(let accountID):
return try await getFeeds(with: nil, accountID: accountID)
}
}
private func getFeeds(with scope: APIService.MastodonNotificationScope) async throws -> [MastodonFeed] {
private func getFeeds(with scope: APIService.MastodonNotificationScope?, accountID: String? = nil) async throws -> [MastodonFeed] {
let notifications = try await context.apiService.notifications(maxID: nil, scope: scope, authenticationBox: authContext.mastodonAuthenticationBox).value
let notifications = try await context.apiService.notifications(maxID: nil, accountID: accountID, scope: scope, authenticationBox: authContext.mastodonAuthenticationBox).value
let accounts = notifications.map { $0.account }
let relationships = try await context.apiService.relationship(forAccounts: accounts, authenticationBox: authContext.mastodonAuthenticationBox).value

View File

@ -10,70 +10,42 @@ import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import OSLog
extension APIService {
public enum MastodonNotificationScope: String, Hashable, CaseIterable {
case everything
case mentions
public var includeTypes: [MastodonNotificationType]? {
switch self {
case .everything: return nil
case .mentions: return [.mention, .status]
}
}
public var excludeTypes: [MastodonNotificationType]? {
switch self {
case .everything: return nil
case .mentions: return [.follow, .followRequest, .reblog, .favourite, .poll]
}
}
public var _excludeTypes: [Mastodon.Entity.Notification.NotificationType]? {
switch self {
case .everything: return nil
case .mentions: return [.follow, .followRequest, .reblog, .favourite, .poll]
}
}
}
public func notifications(
maxID: Mastodon.Entity.Status.ID?,
scope: MastodonNotificationScope,
accountID: String? = nil,
scope: MastodonNotificationScope?,
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Notification]> {
let authorization = authenticationBox.userAuthorization
let types: [Mastodon.Entity.Notification.NotificationType]?
let excludedTypes: [Mastodon.Entity.Notification.NotificationType]?
switch scope {
case .everything:
types = [.follow, .followRequest, .mention, .reblog, .favourite, .poll, .status, .moderationWarning]
excludedTypes = nil
case .mentions:
types = [.mention]
excludedTypes = [.follow, .followRequest, .reblog, .favourite, .poll]
case nil:
types = nil
excludedTypes = nil
}
let query = Mastodon.API.Notifications.Query(
maxID: maxID,
types: {
switch scope {
case .everything:
return [
.follow,
.followRequest,
.mention,
.reblog,
.favourite,
.poll,
.status,
.moderationWarning
]
case .mentions:
return [.mention]
}
}(),
excludeTypes: {
switch scope {
case .everything:
return nil
case .mentions:
return [.follow, .followRequest, .reblog, .favourite, .poll]
}
}()
types: types,
excludeTypes: excludedTypes,
accountID: accountID
)
let response = try await Mastodon.API.Notifications.getNotifications(
@ -107,3 +79,70 @@ extension APIService {
}
}
//MARK: - Notification Policy
extension APIService {
public func notificationPolicy(authenticationBox: MastodonAuthenticationBox) async throws -> Mastodon.Response.Content<Mastodon.Entity.NotificationPolicy> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Notifications.getNotificationPolicy(session: session, domain: domain, authorization: authorization)
return response
}
public func updateNotificationPolicy(
authenticationBox: MastodonAuthenticationBox,
filterNotFollowing: Bool,
filterNotFollowers: Bool,
filterNewAccounts: Bool,
filterPrivateMentions: Bool
) async throws -> Mastodon.Response.Content<Mastodon.Entity.NotificationPolicy> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let query = Mastodon.API.Notifications.UpdateNotificationPolicyQuery(filterNotFollowing: filterNotFollowing, filterNotFollowers: filterNotFollowers, filterNewAccounts: filterNewAccounts, filterPrivateMentions: filterPrivateMentions)
let response = try await Mastodon.API.Notifications.updateNotificationPolicy(
session: session,
domain: domain,
authorization: authorization,
query: query
)
return response
}
}
//MARK: - Notification Requests
extension APIService {
public func notificationRequests(authenticationBox: MastodonAuthenticationBox) async throws -> Mastodon.Response.Content<[Mastodon.Entity.NotificationRequest]> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Notifications.getNotificationRequests(session: session, domain: domain, authorization: authorization)
return response
}
public func acceptNotificationRequests(authenticationBox: MastodonAuthenticationBox, id: String) async throws -> Mastodon.Response.Content<[String: String]> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Notifications.acceptNotificationRequest(id: id, session: session, domain: domain, authorization: authorization)
return response
}
public func rejectNotificationRequests(authenticationBox: MastodonAuthenticationBox, id: String) async throws -> Mastodon.Response.Content<[String: String]> {
let domain = authenticationBox.domain
let authorization = authenticationBox.userAuthorization
let response = try await Mastodon.API.Notifications.dismissNotificationRequest(id: id, session: session, domain: domain, authorization: authorization)
return response
}
}
extension Notification.Name {
public static let notificationFilteringChanged = Notification.Name(rawValue: "org.joinmastodon.app.notificationFilteringsChanged")
}

View File

@ -899,6 +899,14 @@ public enum L10n {
}
}
public enum Notification {
public enum FilteredNotification {
/// Accept
public static let accept = L10n.tr("Localizable", "Scene.Notification.FilteredNotification.Accept", fallback: "Accept")
/// Dismiss
public static let dismiss = L10n.tr("Localizable", "Scene.Notification.FilteredNotification.Dismiss", fallback: "Dismiss")
/// Filtered Notifications
public static let title = L10n.tr("Localizable", "Scene.Notification.FilteredNotification.Title", fallback: "Filtered Notifications")
}
public enum FollowRequest {
/// Accept
public static let accept = L10n.tr("Localizable", "Scene.Notification.FollowRequest.Accept", fallback: "Accept")
@ -929,6 +937,34 @@ public enum L10n {
/// request to follow you
public static let requestToFollowYou = L10n.tr("Localizable", "Scene.Notification.NotificationDescription.RequestToFollowYou", fallback: "request to follow you")
}
public enum Policy {
/// Filter Notifications from
public static let title = L10n.tr("Localizable", "Scene.Notification.Policy.Title", fallback: "Filter Notifications from…")
public enum NewAccount {
/// Created within the past 30 days
public static let subtitle = L10n.tr("Localizable", "Scene.Notification.Policy.NewAccount.Subtitle", fallback: "Created within the past 30 days")
/// New accounts
public static let title = L10n.tr("Localizable", "Scene.Notification.Policy.NewAccount.Title", fallback: "New accounts")
}
public enum NoFollower {
/// Including people who have been following you fewer than 3 days
public static let subtitle = L10n.tr("Localizable", "Scene.Notification.Policy.NoFollower.Subtitle", fallback: "Including people who have been following you fewer than 3 days")
/// People not following you
public static let title = L10n.tr("Localizable", "Scene.Notification.Policy.NoFollower.Title", fallback: "People not following you")
}
public enum NotFollowing {
/// Until you manually approve them
public static let subtitle = L10n.tr("Localizable", "Scene.Notification.Policy.NotFollowing.Subtitle", fallback: "Until you manually approve them")
/// People you don't follow
public static let title = L10n.tr("Localizable", "Scene.Notification.Policy.NotFollowing.Title", fallback: "People you don't follow")
}
public enum PrivateMentions {
/// Filtered unless its in reply to your own mention or if you follow the sender
public static let subtitle = L10n.tr("Localizable", "Scene.Notification.Policy.PrivateMentions.Subtitle", fallback: "Filtered unless its in reply to your own mention or if you follow the sender")
/// Unsolicited private mentions
public static let title = L10n.tr("Localizable", "Scene.Notification.Policy.PrivateMentions.Title", fallback: "Unsolicited private mentions")
}
}
public enum Title {
/// Everything
public static let everything = L10n.tr("Localizable", "Scene.Notification.Title.Everything", fallback: "Everything")
@ -1954,6 +1990,12 @@ public enum L10n {
}
}
}
public enum FilteredNotificationBanner {
/// Plural format key: "%#@number_of_requests@"
public static func subtitle(_ p1: Int) -> String {
return L10n.tr("Localizable", "plural.filtered_notification_banner.subtitle", p1, fallback: "Plural format key: \"%#@number_of_requests@\"")
}
}
}
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length

View File

@ -305,11 +305,11 @@ uploaded to Mastodon.";
"Scene.Following.Title" = "following";
"Scene.HomeTimeline.EmptyState.ListEmptyMessageTitle" = "This list is empty";
"Scene.HomeTimeline.TimelineMenu.Following" = "Following";
"Scene.HomeTimeline.TimelineMenu.LocalCommunity" = "Local";
"Scene.HomeTimeline.TimelineMenu.Lists.Title" = "Lists";
"Scene.HomeTimeline.TimelineMenu.Lists.EmptyMessage" = "You don't have any Lists";
"Scene.HomeTimeline.TimelineMenu.Hashtags.Title" = "Followed Hashtags";
"Scene.HomeTimeline.TimelineMenu.Hashtags.EmptyMessage" = "You don't follow any Hashtags";
"Scene.HomeTimeline.TimelineMenu.Hashtags.Title" = "Followed Hashtags";
"Scene.HomeTimeline.TimelineMenu.Lists.EmptyMessage" = "You don't have any Lists";
"Scene.HomeTimeline.TimelineMenu.Lists.Title" = "Lists";
"Scene.HomeTimeline.TimelineMenu.LocalCommunity" = "Local";
"Scene.HomeTimeline.TimelinePill.NewPosts" = "New Posts";
"Scene.HomeTimeline.TimelinePill.Offline" = "Offline";
"Scene.HomeTimeline.TimelinePill.PostSent" = "Post Sent";
@ -317,6 +317,9 @@ uploaded to Mastodon.";
"Scene.Login.ServerSearchField.Placeholder" = "Enter URL or search for your server";
"Scene.Login.Subtitle" = "Log in with the server where you created your account.";
"Scene.Login.Title" = "Welcome Back";
"Scene.Notification.FilteredNotification.Accept" = "Accept";
"Scene.Notification.FilteredNotification.Dismiss" = "Dismiss";
"Scene.Notification.FilteredNotification.Title" = "Filtered Notifications";
"Scene.Notification.FollowRequest.Accept" = "Accept";
"Scene.Notification.FollowRequest.Accepted" = "Accepted";
"Scene.Notification.FollowRequest.Reject" = "reject";
@ -329,6 +332,15 @@ uploaded to Mastodon.";
"Scene.Notification.NotificationDescription.PollHasEnded" = "poll has ended";
"Scene.Notification.NotificationDescription.RebloggedYourPost" = "boosted your post";
"Scene.Notification.NotificationDescription.RequestToFollowYou" = "request to follow you";
"Scene.Notification.Policy.NewAccount.Subtitle" = "Created within the past 30 days";
"Scene.Notification.Policy.NewAccount.Title" = "New accounts";
"Scene.Notification.Policy.NoFollower.Subtitle" = "Including people who have been following you fewer than 3 days";
"Scene.Notification.Policy.NoFollower.Title" = "People not following you";
"Scene.Notification.Policy.NotFollowing.Subtitle" = "Until you manually approve them";
"Scene.Notification.Policy.NotFollowing.Title" = "People you don't follow";
"Scene.Notification.Policy.PrivateMentions.Subtitle" = "Filtered unless its in reply to your own mention or if you follow the sender";
"Scene.Notification.Policy.PrivateMentions.Title" = "Unsolicited private mentions";
"Scene.Notification.Policy.Title" = "Filter Notifications from…";
"Scene.Notification.Title.Everything" = "Everything";
"Scene.Notification.Title.Mentions" = "Mentions";
"Scene.Notification.Warning.DeleteStatuses" = "Some of your posts have been removed.";
@ -622,4 +634,4 @@ If you disagree with the policy for **%@**, you can go back and pick a different
"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts.";
"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers";
"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";
"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower";

View File

@ -1,481 +1,497 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>a11y.plural.count.unread.notification</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@notification_count_unread_notification@</string>
<key>notification_count_unread_notification</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 unread notification</string>
<key>other</key>
<string>%ld unread notifications</string>
</dict>
</dict>
<key>a11y.plural.count.input_limit_exceeds</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Input limit exceeds %#@character_count@</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 character</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.input_limit_remains</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Input limit remains %#@character_count@</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 character</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.characters_left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@character_count@</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 character left</string>
<key>other</key>
<string>%ld characters left</string>
</dict>
</dict>
<key>plural.count.followed_by_and_mutual</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@names@%#@count_mutual@</string>
<key>names</key>
<dict>
<key>one</key>
<string></string>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>other</key>
<string></string>
</dict>
<key>count_mutual</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>Followed by %1$@, and another mutual</string>
<key>other</key>
<string>Followed by %1$@, and %ld mutuals</string>
</dict>
</dict>
<key>plural.count.metric_formatted.post</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%@ %#@post_count@</string>
<key>post_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>post</string>
<key>other</key>
<string>posts</string>
</dict>
</dict>
<key>plural.count.media</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@media_count@</string>
<key>media_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 media</string>
<key>other</key>
<string>%ld media</string>
</dict>
</dict>
<key>plural.count.post</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@post_count@</string>
<key>post_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 post</string>
<key>other</key>
<string>%ld posts</string>
</dict>
</dict>
<key>plural.count.favorite</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@favorite_count@</string>
<key>favorite_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 favorite</string>
<key>other</key>
<string>%ld favorites</string>
</dict>
</dict>
<key>plural.count.reblog</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@reblog_count@</string>
<key>reblog_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 reblog</string>
<key>other</key>
<string>%ld reblogs</string>
</dict>
</dict>
<key>plural.count.reblog_a11y</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@reblog_count@</string>
<key>reblog_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 re-blog</string>
<key>other</key>
<string>%ld re-blogs</string>
</dict>
</dict>
<key>plural.count.reply</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@reply_count@</string>
<key>reply_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 reply</string>
<key>other</key>
<string>%ld replies</string>
</dict>
</dict>
<key>plural.count.vote</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@vote_count@</string>
<key>vote_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 vote</string>
<key>other</key>
<string>%ld votes</string>
</dict>
</dict>
<key>plural.count.voter</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@voter_count@</string>
<key>voter_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 voter</string>
<key>other</key>
<string>%ld voters</string>
</dict>
</dict>
<key>plural.people_talking</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_people_talking@</string>
<key>count_people_talking</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 people talking</string>
<key>other</key>
<string>%ld people talking</string>
</dict>
</dict>
<key>plural.count.following</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_following@</string>
<key>count_following</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 following</string>
<key>other</key>
<string>%ld following</string>
</dict>
</dict>
<key>plural.count.follower</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_follower@</string>
<key>count_follower</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 follower</string>
<key>other</key>
<string>%ld followers</string>
</dict>
</dict>
<key>date.year.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_year_left@</string>
<key>count_year_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 year left</string>
<key>other</key>
<string>%ld years left</string>
</dict>
</dict>
<key>date.month.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_month_left@</string>
<key>count_month_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 months left</string>
<key>other</key>
<string>%ld months left</string>
</dict>
</dict>
<key>date.day.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_day_left@</string>
<key>count_day_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 day left</string>
<key>other</key>
<string>%ld days left</string>
</dict>
</dict>
<key>date.hour.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_hour_left@</string>
<key>count_hour_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 hour left</string>
<key>other</key>
<string>%ld hours left</string>
</dict>
</dict>
<key>date.minute.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_minute_left@</string>
<key>count_minute_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 minute left</string>
<key>other</key>
<string>%ld minutes left</string>
</dict>
</dict>
<key>date.second.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_second_left@</string>
<key>count_second_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 second left</string>
<key>other</key>
<string>%ld seconds left</string>
</dict>
</dict>
<key>date.year.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_year_ago_abbr@</string>
<key>count_year_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1y ago</string>
<key>other</key>
<string>%ldy ago</string>
</dict>
</dict>
<key>date.month.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_month_ago_abbr@</string>
<key>count_month_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1M ago</string>
<key>other</key>
<string>%ldM ago</string>
</dict>
</dict>
<key>date.day.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_day_ago_abbr@</string>
<key>count_day_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1d ago</string>
<key>other</key>
<string>%ldd ago</string>
</dict>
</dict>
<key>date.hour.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_hour_ago_abbr@</string>
<key>count_hour_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1h ago</string>
<key>other</key>
<string>%ldh ago</string>
</dict>
</dict>
<key>date.minute.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_minute_ago_abbr@</string>
<key>count_minute_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1m ago</string>
<key>other</key>
<string>%ldm ago</string>
</dict>
</dict>
<key>date.second.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_second_ago_abbr@</string>
<key>count_second_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1s ago</string>
<key>other</key>
<string>%lds ago</string>
</dict>
</dict>
</dict>
<dict>
<key>a11y.plural.count.unread.notification</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@notification_count_unread_notification@</string>
<key>notification_count_unread_notification</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 unread notification</string>
<key>other</key>
<string>%ld unread notifications</string>
</dict>
</dict>
<key>a11y.plural.count.input_limit_exceeds</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Input limit exceeds %#@character_count@</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 character</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.input_limit_remains</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>Input limit remains %#@character_count@</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 character</string>
<key>other</key>
<string>%ld characters</string>
</dict>
</dict>
<key>a11y.plural.count.characters_left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@character_count@</string>
<key>character_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 character left</string>
<key>other</key>
<string>%ld characters left</string>
</dict>
</dict>
<key>plural.count.followed_by_and_mutual</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@names@%#@count_mutual@</string>
<key>names</key>
<dict>
<key>one</key>
<string></string>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>other</key>
<string></string>
</dict>
<key>count_mutual</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>Followed by %1$@, and another mutual</string>
<key>other</key>
<string>Followed by %1$@, and %ld mutuals</string>
</dict>
</dict>
<key>plural.count.metric_formatted.post</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%@ %#@post_count@</string>
<key>post_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>post</string>
<key>other</key>
<string>posts</string>
</dict>
</dict>
<key>plural.count.media</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@media_count@</string>
<key>media_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 media</string>
<key>other</key>
<string>%ld media</string>
</dict>
</dict>
<key>plural.count.post</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@post_count@</string>
<key>post_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 post</string>
<key>other</key>
<string>%ld posts</string>
</dict>
</dict>
<key>plural.count.favorite</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@favorite_count@</string>
<key>favorite_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 favorite</string>
<key>other</key>
<string>%ld favorites</string>
</dict>
</dict>
<key>plural.count.reblog</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@reblog_count@</string>
<key>reblog_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 reblog</string>
<key>other</key>
<string>%ld reblogs</string>
</dict>
</dict>
<key>plural.count.reblog_a11y</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@reblog_count@</string>
<key>reblog_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 re-blog</string>
<key>other</key>
<string>%ld re-blogs</string>
</dict>
</dict>
<key>plural.count.reply</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@reply_count@</string>
<key>reply_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 reply</string>
<key>other</key>
<string>%ld replies</string>
</dict>
</dict>
<key>plural.count.vote</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@vote_count@</string>
<key>vote_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 vote</string>
<key>other</key>
<string>%ld votes</string>
</dict>
</dict>
<key>plural.count.voter</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@voter_count@</string>
<key>voter_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 voter</string>
<key>other</key>
<string>%ld voters</string>
</dict>
</dict>
<key>plural.people_talking</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_people_talking@</string>
<key>count_people_talking</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 people talking</string>
<key>other</key>
<string>%ld people talking</string>
</dict>
</dict>
<key>plural.count.following</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_following@</string>
<key>count_following</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 following</string>
<key>other</key>
<string>%ld following</string>
</dict>
</dict>
<key>plural.count.follower</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_follower@</string>
<key>count_follower</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 follower</string>
<key>other</key>
<string>%ld followers</string>
</dict>
</dict>
<key>date.year.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_year_left@</string>
<key>count_year_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 year left</string>
<key>other</key>
<string>%ld years left</string>
</dict>
</dict>
<key>date.month.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_month_left@</string>
<key>count_month_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 months left</string>
<key>other</key>
<string>%ld months left</string>
</dict>
</dict>
<key>date.day.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_day_left@</string>
<key>count_day_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 day left</string>
<key>other</key>
<string>%ld days left</string>
</dict>
</dict>
<key>date.hour.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_hour_left@</string>
<key>count_hour_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 hour left</string>
<key>other</key>
<string>%ld hours left</string>
</dict>
</dict>
<key>date.minute.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_minute_left@</string>
<key>count_minute_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 minute left</string>
<key>other</key>
<string>%ld minutes left</string>
</dict>
</dict>
<key>date.second.left</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_second_left@</string>
<key>count_second_left</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1 second left</string>
<key>other</key>
<string>%ld seconds left</string>
</dict>
</dict>
<key>date.year.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_year_ago_abbr@</string>
<key>count_year_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1y ago</string>
<key>other</key>
<string>%ldy ago</string>
</dict>
</dict>
<key>date.month.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_month_ago_abbr@</string>
<key>count_month_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1M ago</string>
<key>other</key>
<string>%ldM ago</string>
</dict>
</dict>
<key>date.day.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_day_ago_abbr@</string>
<key>count_day_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1d ago</string>
<key>other</key>
<string>%ldd ago</string>
</dict>
</dict>
<key>date.hour.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_hour_ago_abbr@</string>
<key>count_hour_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1h ago</string>
<key>other</key>
<string>%ldh ago</string>
</dict>
</dict>
<key>date.minute.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_minute_ago_abbr@</string>
<key>count_minute_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1m ago</string>
<key>other</key>
<string>%ldm ago</string>
</dict>
</dict>
<key>date.second.ago.abbr</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@count_second_ago_abbr@</string>
<key>count_second_ago_abbr</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>1s ago</string>
<key>other</key>
<string>%lds ago</string>
</dict>
</dict>
<key>plural.filtered_notification_banner.subtitle</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@number_of_requests@</string>
<key>number_of_requests</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>ld</string>
<key>one</key>
<string>One person you may know</string>
<key>other</key>
<string>%ld people you may know</string>
</dict>
</dict>
</dict>
</plist>

View File

@ -134,3 +134,135 @@ extension Mastodon.API.Notifications {
}
}
}
//MARK: - Notification Policy
extension Mastodon.API.Notifications {
internal static func notificationPolicyEndpointURL(domain: String) -> URL {
notificationsEndpointURL(domain: domain).appendingPathComponent("policy")
}
public struct UpdateNotificationPolicyQuery: Codable, PatchQuery {
public let filterNotFollowing: Bool
public let filterNotFollowers: Bool
public let filterNewAccounts: Bool
public let filterPrivateMentions: Bool
enum CodingKeys: String, CodingKey {
case filterNotFollowing = "filter_not_following"
case filterNotFollowers = "filter_not_followers"
case filterNewAccounts = "filter_new_accounts"
case filterPrivateMentions = "filter_private_mentions"
}
public init(filterNotFollowing: Bool, filterNotFollowers: Bool, filterNewAccounts: Bool, filterPrivateMentions: Bool) {
self.filterNotFollowing = filterNotFollowing
self.filterNotFollowers = filterNotFollowers
self.filterNewAccounts = filterNewAccounts
self.filterPrivateMentions = filterPrivateMentions
}
}
public static func getNotificationPolicy(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) async throws -> Mastodon.Response.Content<Mastodon.Entity.NotificationPolicy> {
let request = Mastodon.API.get(
url: notificationPolicyEndpointURL(domain: domain),
authorization: authorization
)
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: Mastodon.Entity.NotificationPolicy.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
public static func updateNotificationPolicy(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization,
query: Mastodon.API.Notifications.UpdateNotificationPolicyQuery
) async throws -> Mastodon.Response.Content<Mastodon.Entity.NotificationPolicy> {
let request = Mastodon.API.patch(
url: notificationPolicyEndpointURL(domain: domain),
query: query,
authorization: authorization
)
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: Mastodon.Entity.NotificationPolicy.self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
}
extension Mastodon.API.Notifications {
internal static func notificationRequestsEndpointURL(domain: String) -> URL {
notificationsEndpointURL(domain: domain).appendingPathComponent("requests")
}
internal static func notificationRequestEndpointURL(domain: String, id: String) -> URL {
notificationRequestsEndpointURL(domain: domain).appendingPathComponent(id)
}
internal static func acceptNotificationRequestEndpointURL(domain: String, id: String) -> URL {
notificationRequestEndpointURL(domain: domain, id: id).appendingPathComponent("accept")
}
internal static func dismissNotificationRequestEndpointURL(domain: String, id: String) -> URL {
notificationRequestEndpointURL(domain: domain, id: id).appendingPathComponent("dismiss")
}
public static func getNotificationRequests(
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.NotificationRequest]> {
let request = Mastodon.API.get(
url: notificationRequestsEndpointURL(domain: domain),
authorization: authorization
)
let (data, response) = try await session.data(for: request)
let value = try Mastodon.API.decode(type: [Mastodon.Entity.NotificationRequest].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
public static func acceptNotificationRequest(
id: String,
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) async throws -> Mastodon.Response.Content<[String: String]> {
let request = Mastodon.API.post(
url: acceptNotificationRequestEndpointURL(domain: domain, id: id),
authorization: authorization
)
let (data, response) = try await session.data(for: request)
// we expect an empty dictionary
let value = try Mastodon.API.decode(type: [String: String].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
public static func dismissNotificationRequest(
id: String,
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) async throws -> Mastodon.Response.Content<[String: String]> {
let request = Mastodon.API.post(
url: dismissNotificationRequestEndpointURL(domain: domain, id: id),
authorization: authorization
)
let (data, response) = try await session.data(for: request)
// we expect an empty dictionary
let value = try Mastodon.API.decode(type: [String: String].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
}

View File

@ -149,7 +149,7 @@ extension Mastodon.API {
static func post(
url: URL,
query: PostQuery?,
query: PostQuery? = nil,
authorization: OAuth.Authorization? = nil
) -> URLRequest {
return buildRequest(url: url, method: .POST, query: query, authorization: authorization)

View File

@ -0,0 +1,31 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import Foundation
extension Mastodon.Entity {
public struct NotificationPolicy: Codable, Hashable {
public let filterNotFollowing: Bool
public let filterNotFollowers: Bool
public let filterNewAccounts: Bool
public let filterPrivateMentions: Bool
public let summary: Summary
enum CodingKeys: String, CodingKey {
case filterNotFollowing = "filter_not_following"
case filterNotFollowers = "filter_not_followers"
case filterNewAccounts = "filter_new_accounts"
case filterPrivateMentions = "filter_private_mentions"
case summary
}
public struct Summary: Codable, Hashable {
public let pendingRequestsCount: Int
public let pendingNotificationsCount: Int
enum CodingKeys: String, CodingKey {
case pendingRequestsCount = "pending_requests_count"
case pendingNotificationsCount = "pending_notifications_count"
}
}
}
}

View File

@ -0,0 +1,23 @@
// Copyright © 2024 Mastodon gGmbH. All rights reserved.
import Foundation
extension Mastodon.Entity {
public struct NotificationRequest: Codable, Hashable {
public let id: String
public let createdAt: Date
public let updatedAt: Date
public let account: Mastodon.Entity.Account
public let notificationsCount: String // contains an `Int`
public let lastStatus: Mastodon.Entity.Status?
enum CodingKeys: String, CodingKey {
case id = "id"
case createdAt = "created_at"
case updatedAt = "updated_at"
case account = "account"
case notificationsCount = "notifications_count"
case lastStatus = "last_status"
}
}
}

View File

@ -10,6 +10,7 @@ public final class MastodonFeed {
case home(timeline: TimelineContext)
case notificationAll
case notificationMentions
case notificationAccount(String)
public enum TimelineContext: Equatable {
case home

View File

@ -55,6 +55,11 @@ extension PostQuery {
// PATCH
protocol PatchQuery: RequestQuery { }
extension PatchQuery {
// By default a `PatchQuery` does not have query items
var queryItems: [URLQueryItem]? { nil }
}
// PUT
protocol PutQuery: RequestQuery { }

View File

@ -290,19 +290,3 @@ extension ActionToolbarContainer {
set { }
}
}
#if DEBUG
import SwiftUI
struct ActionToolbarContainer_Previews: PreviewProvider {
static var previews: some View {
Group {
UIViewPreview(width: 300) {
ActionToolbarContainer()
}
.previewLayout(.fixed(width: 300, height: 44))
.previewDisplayName("Inline")
}
}
}
#endif