first stages of a swiftui list for notifications

changes to keep building that will quickly become moot

Starting to implement SwiftUI version of notifications

project update forgotten

Switch out whole view controller when testing grouped notifications.

make old view work again

Bump deployment target to iOS 17

better view model. follow button loads correctly, showing followers list or account works.

mostly kind of working

rename

rename
This commit is contained in:
shannon 2025-01-16 08:45:26 -05:00
parent 571d73644c
commit 7814812b5a
27 changed files with 1387 additions and 104 deletions

View File

@ -96,7 +96,7 @@
2D6DE40026141DF600A63F6A /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */; };
2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D76319E25C1521200929FB9 /* StatusSection.swift */; };
2D7631A825C1535600929FB9 /* StatusTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */; };
2D7867192625B77500211898 /* NotificationItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationItem.swift */; };
2D7867192625B77500211898 /* NotificationListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D7867182625B77500211898 /* NotificationListItem.swift */; };
2D82B9FF25E7863200E36F0F /* OnboardingViewControllerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */; };
2D82BA0525E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */; };
2D84350525FF858100EECE90 /* UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D84350425FF858100EECE90 /* UIScrollView.swift */; };
@ -742,7 +742,7 @@
2D6DE3FF26141DF600A63F6A /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
2D76319E25C1521200929FB9 /* StatusSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusSection.swift; sourceTree = "<group>"; };
2D7631A725C1535600929FB9 /* StatusTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusTableViewCell.swift; sourceTree = "<group>"; };
2D7867182625B77500211898 /* NotificationItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationItem.swift; sourceTree = "<group>"; };
2D7867182625B77500211898 /* NotificationListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationListItem.swift; sourceTree = "<group>"; };
2D82B9FE25E7863200E36F0F /* OnboardingViewControllerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewControllerAppearance.swift; sourceTree = "<group>"; };
2D82BA0425E7897700E36F0F /* MastodonResendEmailViewModelNavigationDelegateShim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonResendEmailViewModelNavigationDelegateShim.swift; sourceTree = "<group>"; };
2D84350425FF858100EECE90 /* UIScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIScrollView.swift; sourceTree = "<group>"; };
@ -1271,7 +1271,26 @@
FBD689B42CCBF09F00CE29F3 /* DonationCampaignViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonationCampaignViewModel.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
FBBEA04F2D3819080000A900 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
InlinePostPreview.swift,
NotificationListViewController.swift,
NotificationRowView.swift,
NotificationRowViewModel.swift,
TimelinePostCell/ActionButtons.swift,
TimelinePostCell/AuthorHeader.swift,
TimelinePostCell/BoostHeader.swift,
TimelinePostCell/MediaGrid.swift,
TimelinePostCell/TimelinePostCell.swift,
);
target = DB427DD125BAA00100D1B89D /* Mastodon */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
FBBEA04E2D380FC70000A900 /* In Progress New Layout and Datamodel */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (FBBEA04F2D3819080000A900 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "In Progress New Layout and Datamodel"; sourceTree = "<group>"; };
FBC4A4F42D2DA424002E654B /* Beta Testing Settings */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Beta Testing Settings"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
@ -2189,6 +2208,7 @@
DB427DD425BAA00100D1B89D /* Mastodon */ = {
isa = PBXGroup;
children = (
FBBEA04E2D380FC70000A900 /* In Progress New Layout and Datamodel */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
DB427DE325BAA00100D1B89D /* Info.plist */,
2D76319C25C151DE00929FB9 /* Diffable */,
@ -2760,7 +2780,7 @@
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */,
2D607AD726242FC500B70763 /* NotificationViewModel.swift */,
2D35237926256D920031AF25 /* NotificationSection.swift */,
2D7867182625B77500211898 /* NotificationItem.swift */,
2D7867182625B77500211898 /* NotificationListItem.swift */,
);
path = Notification;
sourceTree = "<group>";
@ -3857,7 +3877,7 @@
DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */,
D85DF96D2C481AF700A01408 /* NotificationPolicyHeaderView.swift in Sources */,
DBBE1B4525F3474B0081417A /* MastodonPickServerAppearance.swift in Sources */,
2D7867192625B77500211898 /* NotificationItem.swift in Sources */,
2D7867192625B77500211898 /* NotificationListItem.swift in Sources */,
DB45FAB625CA5485005A8AC7 /* UIAlertController.swift in Sources */,
DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */,
DBEFCD74282A130400C0ABEA /* ReportReasonViewModel.swift in Sources */,
@ -4602,6 +4622,7 @@
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -4632,6 +4653,7 @@
EXCLUDED_SOURCE_FILE_NAMES = "Mastodon/Resources/Preview\\ Assets.xcassets";
INFOPLIST_FILE = Mastodon/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -4817,6 +4839,7 @@
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@ -5117,6 +5140,7 @@
DEVELOPMENT_TEAM = 5Z4GVSS33P;
INFOPLIST_FILE = Mastodon/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",

View File

@ -124,7 +124,7 @@ final public class SceneCoordinator {
)
case .moderationWarning:
break
case ._other:
default:
assertionFailure()
break
}

View File

@ -0,0 +1,59 @@
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
//
// InlinePostPreview.swift
// Design
//
// Created by Sam on 2024-05-08.
//
import SwiftUI
import MastodonSDK
struct InlinePostPreview: View {
let viewModel: Mastodon.Entity.Status.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack(spacing: 4) {
if viewModel.needsUserAttribution {
RoundedRectangle(cornerRadius: 4)
.frame(width: 16, height: 16)
Text(viewModel.accountDisplayName ?? "")
.bold()
Text(viewModel.accountFullName ?? "")
.foregroundStyle(.secondary)
Spacer(minLength: 0)
} else if viewModel.isPinned {
// This *should* be a Label but it acts funky when this is in a List (i.e. in UserList)
Group {
Image(systemName: "pin.fill")
Text("Pinned")
}
.bold()
.foregroundStyle(.secondary)
.imageScale(.small)
}
}
.lineLimit(1)
.font(.subheadline)
Text(viewModel.content)
.lineLimit(3)
}
.padding(8)
.frame(maxWidth: .infinity)
.overlay {
RoundedRectangle(cornerRadius: 8)
.fill(.clear)
.stroke(.separator)
}
}
}
//#Preview {
// VStack {
// InlinePostPreview(post: SampleData.samplePost)
// InlinePostPreview(post: SampleData.samplePost, needsUserAttribution: false, isPinned: true)
// }
// .padding()
//}

View File

@ -0,0 +1,171 @@
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
import SwiftUI
import MastodonCore
import MastodonSDK
import Combine
class NotificationListViewController: UIHostingController<NotificationListView> {
init() {
let viewModel = NotificationListViewModel()
let root = NotificationListView(viewModel: viewModel)
super.init(rootView: root)
viewModel.navigateToScene = { [weak self] scene, transition in
guard let self else { return }
self.sceneCoordinator?.present(scene: scene, from: self, transition: transition)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) not implemented for NotificationListViewController")
}
}
fileprivate enum ListType {
case everything
case mentions
var pickerLabel: String {
switch self {
case .everything:
"EVERYTHING"
case .mentions:
"MENTIONS"
}
}
var feedKind: MastodonFeedKind {
switch self {
case .everything:
return .notificationsAll
case .mentions:
return .notificationsMentionsOnly
}
}
}
extension ListType: Identifiable {
var id: String {
return pickerLabel
}
}
struct NotificationListView: View {
@ObservedObject private var viewModel: NotificationListViewModel
fileprivate init(viewModel: NotificationListViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack {
HStack {
Spacer()
Picker(selection: $viewModel.displayedNotifications) {
ForEach(
[ListType.everything, .mentions]
) {
Text($0.pickerLabel)
.tag($0)
}
} label: {
}
.pickerStyle(.segmented)
Spacer()
}
List {
ForEach(viewModel.notificationItems) { item in
rowView(item)
.onTapGesture {
didTap(item: item)
}
}
}
.listStyle(.plain)
}
}
@ViewBuilder func rowView(_ notificationListItem: NotificationListItem) -> some View {
switch notificationListItem {
case .bottomLoader, .middleLoader:
Text("loader not yet implemented")
case .filteredNotificationsInfo:
Text("filtered notifications not yet implemented")
case .notification(let feedItemIdentifier):
// TODO: implement unread using Mastodon.Entity.Marker
let viewModel = NotificationRowViewModel.viewModel(feedItemIdentifier: feedItemIdentifier, isUnread: false)
GroupedNotificationRowView(viewModel: viewModel)
}
}
func didTap(item: NotificationListItem) {
switch item {
case .filteredNotificationsInfo:
return
case .notification(let identifier):
if let notificationInfo =
MastodonFeedItemCacheManager.shared.cachedItem(identifier) as? NotificationInfo {
guard let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value, let me = authBox.cachedAccount else { return }
switch (notificationInfo.type, notificationInfo.isGrouped) {
case (.follow, false):
guard let notificationAuthor = notificationInfo.primaryAuthorAccount else { return }
viewModel.navigateToScene?(.profile(.notMe(me: me, displayAccount: notificationAuthor, relationship: MastodonFeedItemCacheManager.shared.currentRelationship(toAccount: notificationAuthor.id))), .show)
case (.follow, true):
viewModel.navigateToScene?(.follower(viewModel: FollowerListViewModel(authenticationBox: authBox, domain: me.domain, userID: me.id)), .show)
default:
break
}
}
default:
return
}
}
}
@MainActor
fileprivate class NotificationListViewModel: ObservableObject {
var navigateToScene: ((SceneCoordinator.Scene, SceneCoordinator.Transition)->())?
@Published var displayedNotifications: ListType = .everything {
didSet {
createNewFeedLoader()
}
}
@Published var notificationItems: [NotificationListItem] = []
private var feedSubscription: AnyCancellable?
private var feedLoader = MastodonFeedLoader(kind: .notificationsAll)
init() {
createNewFeedLoader()
}
private func createNewFeedLoader() {
feedLoader = MastodonFeedLoader(kind: displayedNotifications.feedKind)
feedSubscription = feedLoader.$records
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
// TODO: add middle loader and bottom loader?
let updatedItems = records.compactMap {
NotificationListItem.fromMastodonFeedItemIdentifier($0)
}
// TODO: add the filtered notifications announcement if needed
self?.notificationItems = updatedItems
}
feedLoader.loadMore(newestAnchor: nil, oldestAnchor: nil)
}
}
extension NotificationListItem {
static func fromMastodonFeedItemIdentifier(_ feedItem: MastodonFeedItemIdentifier) -> NotificationListItem? {
switch feedItem {
case .notification, .notificationGroup:
return .notification(feedItem)
case .status:
return nil
}
}
}

View File

@ -0,0 +1,387 @@
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
import SwiftUI
import MastodonSDK
import MastodonAsset
import MastodonLocalization
import MastodonCore
import Combine
// TODO: all strings need localization
@MainActor
struct GroupedNotificationRowView: View {
@ObservedObject var viewModel: NotificationRowViewModel
init(viewModel: NotificationRowViewModel) {
self.viewModel = viewModel
}
var body: some View {
HStack(alignment: .top) {
if let iconName = viewModel.type.iconSystemName(grouped: viewModel.grouped) {
NotificationIconView(for: viewModel.type, iconName: iconName)
}
VStack(alignment: .leading) {
contentView()
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
.padding()
.overlay(alignment: .bottom, content: {
Divider()
})
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.background(viewModel.isUnread ? Color(asset: Asset.Colors.accent).opacity(0.1) : .clear)
}
@ViewBuilder func contentView() -> some View {
// TODO: implement unread with Mastodon.Entity.Marker
switch viewModel {
case is FollowNotificationViewModel:
if viewModel.grouped {
AvatarGroupRow(avatars: viewModel.authorAvatarUrls)
Text("\(viewModel.authorsDescription) followed you")
} else {
let viewModel = viewModel as! FollowNotificationViewModel
HStack {
AvatarGroupRow(avatars: viewModel.authorAvatarUrls)
switch viewModel.followButtonAction {
case .action(let buttonText):
Button(buttonText) {}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.bold()
// TODO: implement follow action
case .unfetched, .fetching:
ProgressView()
.progressViewStyle(.circular)
case .noneNeeded, .error:
Spacer().frame(width: 0)
}
}
Text("\(viewModel.authorName) followed you")
}
case is StatusNotificationViewModel:
if viewModel.type == .status {
TimelinePostCell(viewModel.feedItemIdentifier, includePadding: false)
} else {
VStack {
AvatarGroupRow(avatars: viewModel.authorAvatarUrls)
Text("\(viewModel.authorsDescription) \(actionText(forType: viewModel.type)):")
if let postViewModel = viewModel.postViewModel {
InlinePostPreview(viewModel: postViewModel)
}
}
}
// case is SeveredRelationshipsViewModel:
// VStack(alignment: .leading, spacing: 8) {
// Text("An admin from **example.social** has blocked **mastodon.social**, including 4 of your followers and 2 accounts you follow.")
// Button("Learn More", action: {})
// .buttonStyle(.plain)
// .bold()
// .foregroundStyle(Color(asset: Asset.Colors.accent))
// }
// case is PollResultsViewModel:
// Text("\(item.authorName) ran a poll that you and ?? others voted in")
// if let postViewModel = viewModel.postViewModel {
// InlinePostPreview(viewModel: postViewModel)
// }
case is MissingNotificationViewModel:
Text("missing notification info")
default:
Text("not yet implemented")
}
}
func actionText(forType: Mastodon.Entity.NotificationType) -> String {
switch viewModel.type {
case .reblog:
return "boosted"
case .favourite:
return "favourited"
case .mention:
return "mentioned you"
default:
assertionFailure("unexpected notification type")
return ""
}
}
}
struct AvatarGroupRow: View {
let avatars: [URL]
@ScaledMetric private var imageSize: CGFloat = 32
private let avatarShape = RoundedRectangle(cornerRadius: 8)
var body: some View {
HStack(alignment: .center) {
ForEach(avatars, id: \.self) { avatarUrl in
AsyncImage(
url: avatarUrl,
content: { image in
image.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(avatarShape)
},
placeholder: {
avatarShape
.foregroundStyle(Color(UIColor.secondarySystemFill))
}
)
.frame(width: imageSize, height: imageSize)
}
Spacer(minLength: 0)
}
}
}
extension Mastodon.Entity.NotificationType {
func iconSystemName(grouped: Bool = false) -> String? {
switch self {
case .favourite:
return "star"
case .reblog:
return "arrow.2.squarepath"
case .follow:
if grouped {
return "person.2.badge.plus.fill"
} else {
return "person.fill.badge.plus"
}
case .poll:
return "chart.bar.xaxis"
case .adminReport:
return "info.circle"
case .severedRelationships:
return "person.badge.minus"
case .moderationWarning:
return "exclamationmark.shield"
case ._other:
return "questionmark.square.dashed"
case .followRequest, .mention, .status, .update, .adminSignUp:
return nil
}
}
var iconColor: Color {
switch self {
case .favourite:
return .orange
case .reblog:
return .green
case .follow:
return Color(asset: Asset.Colors.accent)
case .poll:
return .secondary
case .adminReport:
return Color(asset: Asset.Colors.accent)
case .severedRelationships:
return .secondary
case .moderationWarning:
return Color(asset: Asset.Colors.accent)
case ._other:
return .gray
case .followRequest, .mention, .status, .update, .adminSignUp:
return .gray
}
}
}
@ViewBuilder
func NotificationIconView(for type: Mastodon.Entity.NotificationType, iconName: String) -> some View {
HStack {
Image(systemName: iconName)
.foregroundStyle(type.iconColor)
}
.font(.system(size: 25))
.frame(width: 44)
.symbolVariant(.fill)
.fontWeight(.semibold)
}
enum AvailableFollowAction: Equatable {
case unfetched
case fetching
case error(Error)
case noneNeeded
case action(buttonText: String)
var description: String {
switch self {
case .unfetched:
return "unfetched"
case .fetching:
return "fetching"
case .error(let error):
return "error(\(error.localizedDescription))"
case .noneNeeded:
return "noneNeeded"
case .action(let buttonText):
return "action(\(buttonText))"
}
}
static func == (lhs: AvailableFollowAction, rhs: AvailableFollowAction) -> Bool {
return lhs.description == rhs.description
}
}
protocol NotificationInfo {
var type: Mastodon.Entity.NotificationType { get }
var isGrouped: Bool { get }
var authorsCount: Int { get }
var primaryAuthorAccount: Mastodon.Entity.Account? { get }
var authorName: String { get }
var authorAvatarUrls: [URL] { get }
func availableFollowAction() async -> AvailableFollowAction?
func fetchAvailableFollowAction() async -> AvailableFollowAction
}
extension NotificationInfo {
var authorsDescription: String {
if authorsCount > 1 {
return "\(authorName) and \(authorsCount - 1) others"
} else {
return authorName
}
}
var avatarCount: Int {
min(authorsCount, 8)
}
var isGrouped: Bool {
return authorsCount > 1
}
}
extension Mastodon.Entity.Notification: NotificationInfo {
var authorsCount: Int { 1 }
var primaryAuthorAccount: Mastodon.Entity.Account? { account }
var authorName: String { account.displayNameWithFallback }
var authorAvatarUrls: [URL] {
if let domain = account.domain {
return [account.avatarImageURLWithFallback(domain: domain)]
} else if let url = account.avatarImageURL() {
return [url]
} else {
return []
}
}
@MainActor
func availableFollowAction() -> AvailableFollowAction? {
if let relationship = MastodonFeedItemCacheManager.shared.currentRelationship(toAccount: account.id) {
if let text = relationship.followButtonText {
return .action(buttonText: text)
}
}
return nil
}
@MainActor
func fetchAvailableFollowAction() async -> AvailableFollowAction {
do {
try await fetchRelationship()
if let availableAction = availableFollowAction() {
return availableAction
} else {
return .noneNeeded
}
} catch {
return .error(error)
}
}
private func fetchRelationship() async throws {
guard let authBox = await AuthenticationServiceProvider.shared.currentActiveUser.value else { return }
let relationship = try await APIService.shared.relationship(forAccounts: [account], authenticationBox: authBox)
await MastodonFeedItemCacheManager.shared.addToCache(relationship)
}
}
extension Mastodon.Entity.NotificationGroup: NotificationInfo {
@MainActor
var primaryAuthorAccount: Mastodon.Entity.Account? {
guard let firstAccountID = sampleAccountIDs.first else { return nil }
return MastodonFeedItemCacheManager.shared.fullAccount(firstAccountID)
}
var authorsCount: Int { notificationsCount }
@MainActor
var authorName: String {
guard let firstAccountID = sampleAccountIDs.first, let firstAccount = MastodonFeedItemCacheManager.shared.fullAccount(firstAccountID) else { return "" }
return firstAccount.displayNameWithFallback
}
@MainActor
var authorAvatarUrls: [URL] {
return sampleAccountIDs
.prefix(avatarCount)
.compactMap { accountID in
let account: NotificationAuthor? = MastodonFeedItemCacheManager.shared.fullAccount(accountID) ?? MastodonFeedItemCacheManager.shared.partialAccount(accountID)
return account?.avatarURL
}
}
@MainActor
var firstAccount: NotificationAuthor? {
guard let firstAccountID = sampleAccountIDs.first else { return nil }
let firstAccount: NotificationAuthor? = MastodonFeedItemCacheManager.shared.fullAccount(firstAccountID) ?? MastodonFeedItemCacheManager.shared.partialAccount(firstAccountID)
return firstAccount
}
@MainActor
func availableFollowAction() -> AvailableFollowAction? {
guard authorsCount == 1 && type == .follow else { return .noneNeeded }
guard let firstAccountID = sampleAccountIDs.first else { return .noneNeeded }
if let relationship = MastodonFeedItemCacheManager.shared.currentRelationship(toAccount: firstAccountID), let text = relationship.followButtonText {
return .action(buttonText: text)
}
return nil
}
@MainActor
func fetchAvailableFollowAction() async -> AvailableFollowAction {
do {
try await fetchRelationship()
if let availableAction = availableFollowAction() {
return availableAction
} else {
return .noneNeeded
}
} catch {
return .error(error)
}
}
func fetchRelationship() async throws {
assert(notificationsCount == 1, "one relationship cannot be assumed representative of \(notificationsCount) notifications")
guard let firstAccountId = sampleAccountIDs.first, let authBox = await AuthenticationServiceProvider.shared.currentActiveUser.value else { return }
if let relationship = try await APIService.shared.relationship(forAccountIds: [firstAccountId], authenticationBox: authBox).value.first {
await MastodonFeedItemCacheManager.shared.addToCache(relationship)
}
}
}
extension Mastodon.Entity.Relationship {
@MainActor
var followButtonText: String? {
if following {
return L10n.Common.Controls.Friendship.following
} else {
if let account: NotificationAuthor = MastodonFeedItemCacheManager.shared.fullAccount(id) ?? MastodonFeedItemCacheManager.shared.partialAccount(id),
account.locked
{
if requested {
return L10n.Common.Controls.Friendship.pending
} else {
return L10n.Common.Controls.Friendship.request
}
}
return L10n.Common.Controls.Friendship.follow
}
}
}

View File

@ -0,0 +1,86 @@
// Copyright © 2025 Mastodon gGmbH. All rights reserved.
import Foundation
import Combine
import MastodonSDK
@MainActor
class NotificationRowViewModel: ObservableObject {
let feedItemIdentifier: MastodonFeedItemIdentifier
let type: Mastodon.Entity.NotificationType
let postViewModel: Mastodon.Entity.Status.ViewModel?
@Published var isUnread: Bool
fileprivate let notificationInfo: NotificationInfo?
let grouped: Bool
let authorAvatarUrls: [URL]
let authorsDescription: String
let authorName: String
static func viewModel(feedItemIdentifier: MastodonFeedItemIdentifier, isUnread: Bool) -> NotificationRowViewModel {
guard let notificationInfo = MastodonFeedItemCacheManager.shared.cachedItem(feedItemIdentifier) as? NotificationInfo else { return MissingNotificationViewModel(nil, feedItemIdentifier: feedItemIdentifier, isUnread: false) }
switch notificationInfo.type {
case .follow:
return FollowNotificationViewModel(notificationInfo, feedItemIdentifier: feedItemIdentifier, isUnread: isUnread)
case .status, .reblog, .mention, .favourite:
return StatusNotificationViewModel(notificationInfo, feedItemIdentifier: feedItemIdentifier, isUnread: isUnread)
default:
return NotificationRowViewModel(notificationInfo, feedItemIdentifier: feedItemIdentifier, isUnread: isUnread)
}
}
init(_ notification: NotificationInfo?, feedItemIdentifier: MastodonFeedItemIdentifier, isUnread: Bool) {
self.notificationInfo = notification
self.type = notification?.type ?? ._other("missing")
self.feedItemIdentifier = feedItemIdentifier
self.postViewModel = MastodonFeedItemCacheManager.shared.statusViewModel(associatedWith: feedItemIdentifier)
self.isUnread = isUnread
let item = MastodonFeedItemCacheManager.shared.cachedItem(feedItemIdentifier) as? NotificationInfo
grouped = item?.isGrouped ?? false
authorName = item?.authorName ?? ""
authorsDescription = item?.authorsDescription ?? ""
authorAvatarUrls = item?.authorAvatarUrls ?? []
}
}
class MissingNotificationViewModel: NotificationRowViewModel {
}
class FollowNotificationViewModel: NotificationRowViewModel {
@Published var followButtonAction: AvailableFollowAction = .unfetched
init(_ notification: NotificationInfo, feedItemIdentifier: MastodonFeedItemIdentifier, isUnread: Bool) {
assert(notification.type == .follow)
super.init(notification, feedItemIdentifier: feedItemIdentifier, isUnread: isUnread)
if notification.type == .follow && !notification.isGrouped {
followButtonAction = .fetching
print("about to fetch for \(notification.authorName)")
updateAvailableFollowAction()
} else {
followButtonAction = .noneNeeded
}
}
private func updateAvailableFollowAction() {
Task {
guard let notificationInfo else { followButtonAction = .noneNeeded; return }
if let followAction = await notificationInfo.availableFollowAction() {
print("had cached answer for \(notificationInfo.authorName)")
followButtonAction = followAction
} else {
print("fetching relationship to derive answer for \(notificationInfo.authorName)")
followButtonAction = await notificationInfo.fetchAvailableFollowAction()
}
}
}
}
class StatusNotificationViewModel: NotificationRowViewModel {
let postedContent: Mastodon.Entity.Status? // TODO: make this non-optional eventually
override init(_ notification: (any NotificationInfo)?, feedItemIdentifier: MastodonFeedItemIdentifier, isUnread: Bool) {
postedContent = MastodonFeedItemCacheManager.shared.filterableStatus(associatedWith: feedItemIdentifier)
assert(postedContent != nil)
super.init(notification, feedItemIdentifier: feedItemIdentifier, isUnread: isUnread)
}
}

View File

@ -0,0 +1,169 @@
//
// ActionButtons.swift
// Design
//
// Created by Sam on 2024-03-28.
//
import SwiftUI
import MastodonSDK
import MastodonAsset
fileprivate func compactNumber(_ int: Int) -> String {
return int.formatted(.number.notation(.compactName))
}
@MainActor
class TimelineActionViewModel: ObservableObject {
private var status: Mastodon.Entity.Status?
@Published private(set) var reply: PostActionType
@Published private(set) var boost: PostActionType
@Published private(set) var favourite: PostActionType
@Published private(set) var isUpdatingBoost: Bool = false
@Published private(set) var isUpdatingFavourite: Bool = false
init(_ feedItemIdentifier: MastodonFeedItemIdentifier) {
if let status = MastodonFeedItemCacheManager.shared.cachedItem(feedItemIdentifier) as? Mastodon.Entity.Status {
reply = .reply(count: status.repliesCount ?? 0)
boost = .boost(count: status.reblogsCount, isSelected: status.reblogged ?? false)
favourite = .favourite(count: status.favouritesCount, isSelected: status.favourited ?? false)
} else {
reply = .reply(count: 0)
boost = .boost(count: 0, isSelected: false)
favourite = .favourite(count: 0, isSelected: false)
}
}
func addReply() {
// TODO: implement
}
func toggleBoost() {
isUpdatingBoost = true
// TODO: implement
// MastodonFeedItemCacheManager.shared.doReblog(status)
// let updatedStatus = try await APIService.shared.reblog(
// status: status,
// authenticationBox: provider.authenticationBox
// ).value
}
func toggleFavourite() {
// TODO: implement
isUpdatingFavourite = true
}
}
extension TimelinePostCell {
struct ActionButtons: View {
@State private var viewModel: TimelineActionViewModel
@MainActor
init(_ identifier: MastodonFeedItemIdentifier) {
viewModel = TimelineActionViewModel(identifier)
}
var body: some View {
HStack(alignment: .center) {
HStack(spacing: 24) {
PostActionButton(actionType: viewModel.reply) {
viewModel.addReply()
}
PostActionButton(actionType: viewModel.boost) {
viewModel.toggleBoost()
}
PostActionButton(actionType: viewModel.favourite) {
viewModel.toggleFavourite()
}
}
Spacer()
PostActionButton(actionType: .share, action: nil)
}
.labelStyle(.iconOnly)
.foregroundStyle(.secondary)
}
}
}
enum PostActionType {
case reply(count: Int)
case boost(count: Int, isSelected: Bool)
case favourite(count: Int, isSelected: Bool)
case share
}
struct PostActionButton: View {
let actionType: PostActionType
var action: (() -> Void)?
var body: some View {
Button(action: { action?() }) {
HStack(spacing: 4) {
Image(systemName: iconName)
.font(.subheadline)
// Adjusted to correctly display the count based on actionType
switch actionType {
case let .reply(count),
let .boost(count, _),
.favourite(let count, _):
ZStack(alignment: .leading) {
Text("0000")
.fontWeight(.semibold)
.hidden()
if count > 0 {
Text(compactNumber(count))
.contentTransition(.numericText(value: Double(count)))
}
}
.font(.footnote)
case .share:
EmptyView()
}
}
.fontWeight(weight)
.foregroundStyle(color)
}
}
private var iconName: String {
switch actionType {
case .reply:
return "bubble.left"
case .boost:
return "arrow.2.squarepath"
case .favourite(_, let isSelected):
return isSelected ? "star.fill" : "star"
case .share:
return "square.and.arrow.up"
}
}
private var weight: SwiftUICore.Font.Weight {
switch actionType {
case .reply:
return .regular
case .boost(_, let isSelected):
return isSelected ? .semibold : .regular
case .favourite(_, let isSelected):
return isSelected ? .semibold : .regular
case .share:
return .regular
}
}
private var color: Color {
switch actionType {
case .reply, .share:
return .secondary
case .boost(_, let isSelected):
return isSelected ? Color(asset: Asset.Colors.accent) : .secondary
case .favourite(_, let isSelected):
return isSelected ? .orange : .secondary
}
}
}
//#Preview {
// TimelinePostCell.ActionButtons()
//}

View File

@ -0,0 +1,34 @@
//
// AuthorHeader.swift
// Design
//
// Created by Sam on 2024-03-27.
//
import SwiftUI
extension TimelinePostCell {
struct AuthorHeader: View {
let displayName: String
let fullAccountName: String
var body: some View {
HStack(spacing: 4) {
Text(displayName)
.bold()
.foregroundStyle(.primary)
Text(verbatim: fullAccountName)
Spacer()
Text("5m")
}
.foregroundStyle(.secondary)
.lineLimit(1)
.font(.callout)
}
}
}
//#Preview {
// TimelinePostCell.AuthorHeader()
//}

View File

@ -0,0 +1,36 @@
//
// BoostHeader.swift
// Design
//
// Created by Sam on 2024-03-28.
//
import SwiftUI
extension TimelinePostCell {
struct BoostHeader: View {
let boostingAccountName: String
var body: some View {
GridRow {
Image(systemName: "repeat")
.gridColumnAlignment(.trailing)
HStack(spacing: 0) {
Text(boostingAccountName)
.truncationMode(.tail)
.fontWeight(.semibold)
Text(" boosted")
}
}
.foregroundStyle(.secondary)
.lineLimit(1)
.font(.subheadline)
}
}
}
//#Preview {
// Grid {
// TimelinePostCell.BoostHeader()
// }
//}

View File

@ -0,0 +1,64 @@
//
// MediaGrid.swift
// Design
//
// Created by Sam on 2024-03-28.
//
import SwiftUI
let images = [
Image("ultrawide").resizable(),
Image("screenshot").resizable(),
Image("portrait").resizable(),
Image("landscape").resizable(),
Image("square").resizable()
]
extension TimelinePostCell {
struct MediaGrid: View {
var body: some View {
VStack {
Grid {
GridRow {
Rectangle()
.background {
images[0]
.aspectRatio(contentMode: .fill)
}
.clipped()
Rectangle()
.background {
images[1]
.aspectRatio(contentMode: .fill)
}
.clipped()
}
.aspectRatio(3/4, contentMode: .fit)
GridRow {
Rectangle()
.background {
images[4]
.aspectRatio(contentMode: .fill)
}
.clipped()
Rectangle()
.background {
images[3]
.aspectRatio(contentMode: .fill)
}
.clipped()
}
.aspectRatio(4/3, contentMode: .fit)
}
.foregroundStyle(.clear)
}
}
}
}
#Preview {
TimelinePostCell.MediaGrid()
}

View File

@ -0,0 +1,96 @@
//
// TimelinePostCell.swift
// Design
//
// Created by Sam on 2024-03-27.
//
import SwiftUI
import MastodonSDK
@MainActor
class TimelinePostViewModel {
let feedItemIdentifier: MastodonFeedItemIdentifier
let statusItemIdentifier: MastodonFeedItemIdentifier?
let authorAvatarUrl: URL?
let boostingAccountName: String?
let authorAccountName: String?
let authorAccountFullNameWithDomain: String?
let includePadding: Bool
let showMediaGrid = false
// TODO: give this something that conforms to TimelinePostInfo, rather than a generic feed item identifier
init(feedItemIdentifier: MastodonFeedItemIdentifier, includePadding: Bool) {
self.includePadding = includePadding
self.feedItemIdentifier = feedItemIdentifier
if let notification = MastodonFeedItemCacheManager.shared.cachedItem(feedItemIdentifier) as? NotificationInfo {
boostingAccountName = notification.type == .reblog ? notification.authorName : nil
} else {
boostingAccountName = nil
}
if let status = MastodonFeedItemCacheManager.shared.filterableStatus(associatedWith: feedItemIdentifier) {
statusItemIdentifier = .status(id: status.id)
authorAccountName = status.account.displayNameWithFallback
authorAccountFullNameWithDomain = status.account.acctWithDomain
authorAvatarUrl = status.account.avatarURL
} else {
statusItemIdentifier = nil
authorAvatarUrl = nil
authorAccountName = nil
authorAccountFullNameWithDomain = nil
}
}
}
struct TimelinePostCell: View {
private let viewModel: TimelinePostViewModel
init(_ identifier: MastodonFeedItemIdentifier, includePadding: Bool = true) {
viewModel = TimelinePostViewModel(feedItemIdentifier: identifier, includePadding: includePadding)
}
var body: some View {
Grid(alignment: .leading, verticalSpacing: 4) {
if let boostingAccountName = viewModel.boostingAccountName {
BoostHeader(boostingAccountName: boostingAccountName)
}
GridRow(alignment: .top) {
if let authorAvatarUrl = viewModel.authorAvatarUrl {
AsyncImage(url: authorAvatarUrl)
//.resizable()
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(.separator, lineWidth: 1)
.blendMode(.plusLighter)
}
.offset(x: 0, y: 4)
}
VStack(alignment: .leading) {
if let authorAccountName = viewModel.authorAccountName, let authorAccountFullNameWithDomain = viewModel.authorAccountFullNameWithDomain {
AuthorHeader(displayName: authorAccountName, fullAccountName: authorAccountFullNameWithDomain)
}
if let content = MastodonFeedItemCacheManager.shared.statusViewModel(associatedWith: viewModel.feedItemIdentifier)?.content {
Text(content)
.font(.callout)
}
if viewModel.showMediaGrid {
MediaGrid()
}
if let statusItemIdentifier = viewModel.statusItemIdentifier {
ActionButtons(statusItemIdentifier)
.padding(.vertical, 4)
}
}
}
}
.padding(.all, viewModel.includePadding ? nil : 0)
}
}
//
//#Preview {
// TimelinePostCell()
//}

View File

@ -1,17 +0,0 @@
//
// NotificationItem.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import CoreData
import Foundation
import MastodonSDK
enum NotificationItem: Hashable {
case filteredNotificationsInfo(policy: Mastodon.Entity.NotificationPolicy)
case notification(MastodonFeedItemIdentifier)
case feedLoader(MastodonFeedItemIdentifier)
case bottomLoader
}

View File

@ -0,0 +1,34 @@
//
// NotificationItem.swift
// Mastodon
//
// Created by sxiaojian on 2021/4/13.
//
import CoreData
import Foundation
import MastodonSDK
enum NotificationListItem: Hashable {
case filteredNotificationsInfo(policy: Mastodon.Entity.NotificationPolicy)
case notification(MastodonFeedItemIdentifier)
case middleLoader(after: MastodonFeedItemIdentifier, before: MastodonFeedItemIdentifier)
case bottomLoader // TODO: not sure we need/want this?
}
extension NotificationListItem: Identifiable {
typealias ID = String
var id: ID {
switch self {
case .filteredNotificationsInfo:
return "filtered_notifications_info"
case .notification(let identifier):
return identifier.id
case let .middleLoader(afterID, beforeID):
return afterID.id+"-"+beforeID.id
case .bottomLoader:
return "bottom_loader"
}
}
}

View File

@ -33,7 +33,7 @@ extension NotificationSection {
static func diffableDataSource(
tableView: UITableView,
configuration: Configuration
) -> UITableViewDiffableDataSource<NotificationSection, NotificationItem> {
) -> UITableViewDiffableDataSource<NotificationSection, NotificationListItem> {
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))
@ -57,7 +57,7 @@ extension NotificationSection {
return cell
}
case .feedLoader:
case .middleLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell
cell.activityIndicatorView.startAnimating()
return cell
@ -78,12 +78,14 @@ extension NotificationSection {
extension NotificationSection {
@MainActor
static func configure(
tableView: UITableView,
cell: NotificationTableViewCell,
itemIdentifier: MastodonFeedItemIdentifier,
configuration: Configuration
) {
guard let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { assertionFailure(); return }
StatusSection.setupStatusPollDataSource(
authenticationBox: configuration.authenticationBox,
statusView: cell.notificationView.statusView

View File

@ -58,7 +58,7 @@ extension NotificationTimelineViewController: DataSourceProvider {
}
case .filteredNotificationsInfo(let policy):
return DataSourceItem.notificationBanner(policy: policy)
case .bottomLoader, .feedLoader:
case .bottomLoader, .middleLoader:
return nil
}
}

View File

@ -122,7 +122,8 @@ extension NotificationTimelineViewController {
@objc private func refreshControlValueChanged(_ sender: RefreshControl) {
Task {
let policy = try? await APIService.shared.notificationPolicy(authenticationBox: authenticationBox)
guard let authBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { return }
let policy = try? await APIService.shared.notificationPolicy(authenticationBox: authBox)
viewModel.notificationPolicy = policy?.value
await viewModel.loadLatest()
@ -133,7 +134,7 @@ extension NotificationTimelineViewController {
// MARK: - AuthContextProvider
extension NotificationTimelineViewController: AuthContextProvider {
var authenticationBox: MastodonAuthenticationBox { viewModel.authenticationBox }
var authenticationBox: MastodonAuthenticationBox { AuthenticationServiceProvider.shared.currentActiveUser.value! }
}
// MARK: - UITableViewDelegate
@ -226,7 +227,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
return
}
let _navigateToItem: NotificationItem? = {
let _navigateToItem: NotificationListItem? = {
var index = selectedItemIndex
while 0..<items.count ~= index {
index = {
@ -253,7 +254,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
guard let indexPathsForVisibleRows = tableView.indexPathsForVisibleRows else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
var visibleItems: [NotificationItem] = indexPathsForVisibleRows.sorted().compactMap { indexPath in
var visibleItems: [NotificationListItem] = indexPathsForVisibleRows.sorted().compactMap { indexPath in
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return nil }
guard Self.validNavigateableItem(item) else { return nil }
return item
@ -267,7 +268,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
tableView.selectRow(at: indexPath, animated: true, scrollPosition: scrollPosition)
}
static func validNavigateableItem(_ item: NotificationItem) -> Bool {
static func validNavigateableItem(_ item: NotificationListItem) -> Bool {
switch item {
case .notification:
return true
@ -287,7 +288,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
let status: Mastodon.Entity.Status?
let account: Mastodon.Entity.Account?
switch notificationItem {
case .notification(let id):
case .notification:
guard let notification = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.Notification else {
status = nil
account = nil
@ -296,7 +297,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
status = notification.status
account = notification.account
case .notificationGroup(let id):
case .notificationGroup:
guard let notificationGroup = MastodonFeedItemCacheManager.shared.cachedItem(notificationItem) as? Mastodon.Entity.NotificationGroup else {
status = nil
account = nil
@ -321,7 +322,7 @@ extension NotificationTimelineViewController: TableViewControllerNavigateable {
if let status {
let threadViewModel = ThreadViewModel(
authenticationBox: self.viewModel.authenticationBox,
authenticationBox: self.authenticationBox,
optionalRoot: .root(context: .init(status: .fromEntity(status)))
)
_ = self.sceneCoordinator?.present(

View File

@ -25,7 +25,7 @@ extension NotificationTimelineViewModel {
)
)
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationListItem>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot)
@ -37,11 +37,11 @@ extension NotificationTimelineViewModel {
Task {
let oldSnapshot = diffableDataSource.snapshot()
let newSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem> = {
let newSnapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationListItem> = {
let newItems = records.map { record in
NotificationItem.notification(record)
NotificationListItem.notification(record)
}
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>()
var snapshot = NSDiffableDataSourceSnapshot<NotificationSection, NotificationListItem>()
snapshot.appendSections([.main])
if self.scope == .everything, let notificationPolicy = self.notificationPolicy, notificationPolicy.summary.pendingRequestsCount > 0 {
snapshot.appendItems([.filteredNotificationsInfo(policy: notificationPolicy)])
@ -67,7 +67,7 @@ extension NotificationTimelineViewModel {
extension NotificationTimelineViewModel {
@MainActor func updateSnapshotUsingReloadData(
snapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationItem>
snapshot: NSDiffableDataSourceSnapshot<NotificationSection, NotificationListItem>
) async {
await self.diffableDataSource?.applySnapshotUsingReloadData(snapshot)
}

View File

@ -27,7 +27,7 @@ final class NotificationTimelineViewModel {
@Published var lastAutomaticFetchTimestamp: Date?
// output
var diffableDataSource: UITableViewDiffableDataSource<NotificationSection, NotificationItem>?
var diffableDataSource: UITableViewDiffableDataSource<NotificationSection, NotificationListItem>?
var didLoadLatest = PassthroughSubject<Void, Never>()
// bottom loader
@ -53,7 +53,7 @@ final class NotificationTimelineViewModel {
self.authenticationBox = authenticationBox
self.scope = scope
let useGroupedNotifications = UserDefaults.standard.useGroupedNotifications
self.feedLoader = MastodonFeedLoader(authenticationBox: authenticationBox, kind: scope.feedKind)
self.feedLoader = MastodonFeedLoader(kind: scope.feedKind)
self.notificationPolicy = notificationPolicy
NotificationCenter.default.addObserver(self, selector: #selector(Self.notificationFilteringChanged(_:)), name: .notificationFilteringChanged, object: nil)
@ -109,7 +109,7 @@ extension NotificationTimelineViewModel {
func loadLatest() async {
isLoadingLatest = true
defer { isLoadingLatest = false }
feedLoader.loadInitial(kind: scope.feedKind)
feedLoader.loadMore(newestAnchor: nil, oldestAnchor: nil)
didLoadLatest.send()
}

View File

@ -71,7 +71,7 @@ extension NotificationView {
case .moderationWarning:
// case handled in `AccountWarningNotificationCell.swift`
break
case ._other:
default:
setAuthorContainerBottomPaddingViewDisplay()
assertionFailure()
}
@ -103,7 +103,7 @@ extension NotificationView {
case .moderationWarning:
// case handled in `AccountWarningNotificationCell.swift`
break
case ._other:
default:
setAuthorContainerBottomPaddingViewDisplay()
assertionFailure()
}
@ -136,7 +136,7 @@ extension NotificationView {
case .moderationWarning:
// case handled in `AccountWarningNotificationCell.swift`
break
case ._other:
default:
setAuthorContainerBottomPaddingViewDisplay()
assertionFailure()
}
@ -169,7 +169,7 @@ extension NotificationView {
case .moderationWarning:
// case handled in `AccountWarningNotificationCell.swift`
break
case ._other:
default:
setAuthorContainerBottomPaddingViewDisplay()
assertionFailure()
}
@ -260,7 +260,7 @@ extension NotificationView {
case .moderationWarning:
#warning("Not implemented")
notificationIndicatorText = createMetaContent(text: "Moderation Warning", emojis: author.emojis.asDictionary)
case ._other:
default:
notificationIndicatorText = nil
}
@ -630,6 +630,7 @@ extension MastodonFollowRequestState.State {
protocol NotificationAuthor {
var avatarURL: URL? { get }
var locked: Bool { get }
}
extension Mastodon.Entity.Account: NotificationAuthor {

View File

@ -35,7 +35,7 @@ class MainTabBarController: UITabBarController {
let homeTimelineViewController: HomeTimelineViewController
let searchViewController: SearchViewController
let composeViewController: UIViewController // placeholder
let notificationViewController: NotificationViewController
let notificationViewController: UIViewController
var meProfileViewController: UIViewController // placeholder
private(set) var isReadyForWizardAvatarButton = false
@ -59,15 +59,22 @@ class MainTabBarController: UITabBarController {
composeViewController = UIViewController()
composeViewController.configureTabBarItem(with: .compose)
notificationViewController = NotificationViewController()
if BetaTestSettingsViewModel().testGroupedNotifications {
notificationViewController = NotificationListViewController()
} else {
notificationViewController = NotificationViewController()
}
notificationViewController.configureTabBarItem(with: .notifications)
meProfileViewController = UIViewController()
meProfileViewController.configureTabBarItem(with: .me)
if let authenticationBox {
notificationViewController.viewModel = NotificationViewModel(context: AppContext.shared, authenticationBox: authenticationBox)
if let notificationController = notificationViewController as? NotificationViewController {
notificationController.viewModel = NotificationViewModel(context: AppContext.shared, authenticationBox: authenticationBox)
}
homeTimelineViewController.viewModel = HomeTimelineViewModel(authenticationBox: authenticationBox)
searchViewController.viewModel = SearchViewModel(authenticationBox: authenticationBox)
}

View File

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

View File

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0xE6",
"green" : "0x45",
"red" : "0x61"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0xF2",
"green" : "0x62",
"red" : "0x70"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -102,6 +102,7 @@ public enum Asset {
public static let invalid = ColorAsset(name: "Colors/TextField/invalid")
public static let valid = ColorAsset(name: "Colors/TextField/valid")
}
public static let accent = ColorAsset(name: "Colors/accent")
public static let alertYellow = ColorAsset(name: "Colors/alert.yellow")
public static let badgeBackground = ColorAsset(name: "Colors/badge.background")
public static let battleshipGrey = ColorAsset(name: "Colors/battleshipGrey")

View File

@ -19,16 +19,14 @@ final public class MastodonFeedLoader {
@Published public private(set) var records: [MastodonFeedItemIdentifier] = []
private let authenticationBox: MastodonAuthenticationBox
private let kind: MastodonFeedKind
private var subscriptions = Set<AnyCancellable>()
private var activeFilterBoxSubscription: AnyCancellable?
public init(authenticationBox: MastodonAuthenticationBox, kind: MastodonFeedKind) {
self.authenticationBox = authenticationBox
public init(kind: MastodonFeedKind) {
self.kind = kind
StatusFilterService.shared.$activeFilterBox
activeFilterBoxSubscription = StatusFilterService.shared.$activeFilterBox
.sink { filterBox in
if filterBox != nil {
Task { [weak self] in
@ -37,29 +35,28 @@ final public class MastodonFeedLoader {
}
}
}
.store(in: &subscriptions)
}
private func setRecordsAfterFiltering(_ newRecords: [MastodonFeedItemIdentifier]) async {
guard let filterBox = StatusFilterService.shared.activeFilterBox else { self.records = newRecords; return }
let filtered = await self.filter(newRecords, forFeed: kind, with: filterBox)
self.records = filtered.removingDuplicates()
public func loadMore(newestAnchor: MastodonFeedItemIdentifier?, oldestAnchor: MastodonFeedItemIdentifier?) {
switch (newestAnchor, oldestAnchor) {
case (_, nil):
loadInitial(kind: kind)
case (_, let oldestAnchor):
Task {
let unfiltered = try await load(kind: kind, olderThan: oldestAnchor?.id)
await setRecordsAfterFiltering(unfiltered)
}
}
}
private func appendRecordsAfterFiltering(_ additionalRecords: [MastodonFeedItemIdentifier]) async {
guard let filterBox = StatusFilterService.shared.activeFilterBox else { self.records += additionalRecords; return }
let newRecords = await self.filter(additionalRecords, forFeed: kind, with: filterBox)
self.records = (self.records + newRecords).removingDuplicates()
}
public func loadInitial(kind: MastodonFeedKind) {
private func loadInitial(kind: MastodonFeedKind) {
Task {
let unfilteredRecords = try await load(kind: kind)
await setRecordsAfterFiltering(unfilteredRecords)
}
}
public func loadNext(kind: MastodonFeedKind) {
private func loadNext(kind: MastodonFeedKind) {
Task {
guard let lastId = records.last?.id else {
return loadInitial(kind: kind)
@ -70,19 +67,15 @@ final public class MastodonFeedLoader {
}
}
private func filter(_ records: [MastodonFeedItemIdentifier], forFeed feedKind: MastodonFeedKind, with filterBox: Mastodon.Entity.FilterBox) async -> [MastodonFeedItemIdentifier] {
let filteredRecords = records.filter { itemIdentifier in
guard let status = MastodonFeedItemCacheManager.shared.filterableStatus(associatedWith: itemIdentifier) else { return true }
let filterResult = filterBox.apply(to: status, in: feedKind.filterContext)
switch filterResult {
case .hide:
return false
default:
return true
}
private func load(kind: MastodonFeedKind, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] {
switch kind {
case .notificationsAll:
return try await loadNotifications(withScope: .everything, olderThan: maxID)
case .notificationsMentionsOnly:
return try await loadNotifications(withScope: .mentions, olderThan: maxID)
case .notificationsWithAccount(let accountID):
return try await loadNotifications(withAccountID: accountID, olderThan: maxID)
}
return filteredRecords
}
// TODO: all of these updates should happen the cached item, and then any cells referencing them should be reconfigured
@ -217,19 +210,38 @@ final public class MastodonFeedLoader {
// }
}
// MARK: - Filtering
private extension MastodonFeedLoader {
func load(kind: MastodonFeedKind, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] {
switch kind {
case .notificationsAll:
return try await loadNotifications(withScope: .everything, olderThan: maxID)
case .notificationsMentionsOnly:
return try await loadNotifications(withScope: .mentions, olderThan: maxID)
case .notificationsWithAccount(let accountID):
return try await loadNotifications(withAccountID: accountID, olderThan: maxID)
}
private func setRecordsAfterFiltering(_ newRecords: [MastodonFeedItemIdentifier]) async {
guard let filterBox = StatusFilterService.shared.activeFilterBox else { self.records = newRecords; return }
let filtered = await self.filter(newRecords, forFeed: kind, with: filterBox)
self.records = filtered.removingDuplicates()
}
private func appendRecordsAfterFiltering(_ additionalRecords: [MastodonFeedItemIdentifier]) async {
guard let filterBox = StatusFilterService.shared.activeFilterBox else { self.records += additionalRecords; return }
let newRecords = await self.filter(additionalRecords, forFeed: kind, with: filterBox)
self.records = (self.records + newRecords).removingDuplicates()
}
private func filter(_ records: [MastodonFeedItemIdentifier], forFeed feedKind: MastodonFeedKind, with filterBox: Mastodon.Entity.FilterBox) async -> [MastodonFeedItemIdentifier] {
let filteredRecords = records.filter { itemIdentifier in
guard let status = MastodonFeedItemCacheManager.shared.filterableStatus(associatedWith: itemIdentifier) else { return true }
let filterResult = filterBox.apply(to: status, in: feedKind.filterContext)
switch filterResult {
case .hide:
return false
default:
return true
}
}
return filteredRecords
}
}
// MARK: - Notifications
private extension MastodonFeedLoader {
private func loadNotifications(withScope scope: APIService.MastodonNotificationScope, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] {
let useGroupedNotifications = UserDefaults.standard.useGroupedNotifications
if useGroupedNotifications {
@ -251,6 +263,7 @@ private extension MastodonFeedLoader {
private func _getUngroupedNotifications(withScope scope: APIService.MastodonNotificationScope? = nil, accountID: String? = nil, olderThan maxID: String? = nil) async throws -> [MastodonFeedItemIdentifier] {
assert(scope != nil || accountID != nil, "need a scope or an accountID")
guard let authenticationBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { throw APIService.APIError.implicit(.authenticationMissing) }
let notifications = try await APIService.shared.notifications(olderThan: maxID, fromAccount: accountID, scope: scope, authenticationBox: authenticationBox).value
@ -259,9 +272,11 @@ private extension MastodonFeedLoader {
for relationship in relationships {
MastodonFeedItemCacheManager.shared.addToCache(relationship)
}
for notification in notifications {
MastodonFeedItemCacheManager.shared.addToCache(notification)
}
return notifications.map {
MastodonFeedItemCacheManager.shared.addToCache($0)
return MastodonFeedItemIdentifier.notification(id: $0.id)
}
}
@ -270,6 +285,8 @@ private extension MastodonFeedLoader {
assert(scope != nil || accountID != nil, "need a scope or an accountID")
guard let authenticationBox = AuthenticationServiceProvider.shared.currentActiveUser.value else { throw APIService.APIError.implicit(.authenticationMissing) }
let results = try await APIService.shared.groupedNotifications(olderThan: maxID, fromAccount: accountID, scope: scope, authenticationBox: authenticationBox).value
for account in results.accounts {
@ -280,12 +297,15 @@ private extension MastodonFeedLoader {
MastodonFeedItemCacheManager.shared.addToCache(partialAccount)
}
}
for status in results.statuses {
MastodonFeedItemCacheManager.shared.addToCache(status)
}
for group in results.notificationGroups {
MastodonFeedItemCacheManager.shared.addToCache(group)
}
return results.notificationGroups.map {
MastodonFeedItemCacheManager.shared.addToCache($0)
return MastodonFeedItemIdentifier.notificationGroup(id: $0.id)
}
}

View File

@ -24,8 +24,15 @@ extension APIService {
forAccounts accounts: [Mastodon.Entity.Account],
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> {
let ids: [String] = accounts.compactMap { $0.id }
return try await relationship(forAccountIds: ids, authenticationBox: authenticationBox)
}
public func relationship(
forAccountIds ids: [String],
authenticationBox: MastodonAuthenticationBox
) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Relationship]> {
guard ids.isEmpty == false else { throw APIError.implicit(.badRequest) }

View File

@ -62,8 +62,8 @@ extension Mastodon.Entity {
public let latestPageNotificationAt: Date? // Date at which the most recent notification from this group within the current page has been created. This is only returned when paginating through notification groups.
public let sampleAccountIDs: [String] // IDs of some of the accounts who most recently triggered notifications in this group.
public let statusID: ID?
public let report: Report?
public let relationshipSeveranceEvent: RelationshipSeveranceEvent?
public let report: Report? // Attached when type of the notification is admin.report
public let relationshipSeveranceEvent: RelationshipSeveranceEvent? // Attached when type of the notification is severed_relationships
public let accountWarning: AccountWarning?
enum CodingKeys: String, CodingKey {
@ -224,14 +224,18 @@ extension Mastodon.Entity {
extension Mastodon.Entity {
public enum NotificationType: RawRepresentable, Codable, Sendable {
case follow
case followRequest
case mention
case reblog
case favourite
case poll
case status
case moderationWarning
case follow // Someone followed you
case followRequest // Someone requested to follow you
case mention // Someone mentioned you in their status
case reblog // Someone boosted one of your statuses
case favourite // Someone favourited one of your statuses
case poll // A poll you have voted in or created has ended
case status // Someone you enabled notifications for has posted a status
case update // A status you interacted with has been edited
case adminSignUp // Someone signed up (optionally sent to admins)
case adminReport // A new report has been filed
case severedRelationships // Some of your follow relationships have been severed as a result of a moderation or block event
case moderationWarning // A moderator has taken action against your account or has sent you a warning
case _other(String)
@ -258,6 +262,10 @@ extension Mastodon.Entity {
case .favourite: return "favourite"
case .poll: return "poll"
case .status: return "status"
case .update: return "update"
case .adminSignUp: return "admin.sign_up"
case .adminReport: return "admin.report"
case .severedRelationships: return "severed_relationships"
case .moderationWarning: return "moderation_warning"
case ._other(let value): return value
}

View File

@ -136,11 +136,44 @@ public enum MastodonFeedKind {
case notificationsWithAccount(String)
}
public extension Mastodon.Entity.Status {
struct ViewModel {
public let content: AttributedString
public let isPinned: Bool
public let accountDisplayName: String?
public let accountFullName: String?
public var needsUserAttribution: Bool {
return accountDisplayName != nil || accountFullName != nil
}
}
@MainActor
func viewModel() -> ViewModel {
let displayableContent = contentAsAttributedString(content)
return ViewModel(content: displayableContent, isPinned: false, accountDisplayName: account.displayName, accountFullName: account.acctWithDomain)
}
@MainActor
private func contentAsAttributedString(_ htmlContent: String?) -> AttributedString {
guard let htmlContent else { return AttributedString() }
let data = Data(htmlContent.utf8)
do {
let attributedString = try NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html], documentAttributes: nil)
return AttributedString(attributedString)
} catch {
return AttributedString(error.localizedDescription)
}
}
}
@MainActor
public class MastodonFeedItemCacheManager {
private var statusCache = [ String : Mastodon.Entity.Status ]()
private var statusViewModelCache = [ String : Mastodon.Entity.Status.ViewModel ]()
private var notificationsCache = [ String : Mastodon.Entity.Notification ]()
private var groupedNotificationsCache = [ String : Mastodon.Entity.NotificationGroup ]()
private var relationshipsCache = [ String : Mastodon.Entity.Relationship ]()
private var relationshipsCache = [ String : Mastodon.Entity.Relationship ]() // key is id of the not-me account
private var fullAccountsCache = [ String : Mastodon.Entity.Account ]()
private var partialAccountsCache = [ String : Mastodon.Entity.PartialAccountWithAvatar ]()
private var filterOverrides = Set<String>()
@ -160,6 +193,8 @@ public class MastodonFeedItemCacheManager {
public func addToCache(_ item: Any) {
if let status = item as? Mastodon.Entity.Status {
statusCache[status.id] = status
let displayableStatus = status.reblog ?? status
statusViewModelCache[status.id] = displayableStatus.viewModel()
} else if let notification = item as? Mastodon.Entity.Notification {
notificationsCache[notification.id] = notification
} else if let notificationGroup = item as? Mastodon.Entity.NotificationGroup {
@ -187,6 +222,20 @@ public class MastodonFeedItemCacheManager {
}
}
public func statusViewModel(associatedWith identifier: MastodonFeedItemIdentifier) -> Mastodon.Entity.Status.ViewModel? {
if case let .status(id) = identifier {
if let cachedModel = statusViewModelCache[id] {
return cachedModel
}
}
guard let cachedStatus = filterableStatus(associatedWith: identifier) else { return nil }
if let cachedModel = statusViewModelCache[cachedStatus.id] {
return cachedModel
}
guard let id = cachedStatus.reblog?.id else { return nil }
return statusViewModelCache[id]
}
public func filterableStatus(associatedWith identifier: MastodonFeedItemIdentifier) -> Mastodon.Entity.Status? {
guard let cachedItem = cachedItem(identifier) else { return nil }
if let status = cachedItem as? Mastodon.Entity.Status {