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:
parent
571d73644c
commit
7814812b5a
@ -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",
|
||||
|
@ -124,7 +124,7 @@ final public class SceneCoordinator {
|
||||
)
|
||||
case .moderationWarning:
|
||||
break
|
||||
case ._other:
|
||||
default:
|
||||
assertionFailure()
|
||||
break
|
||||
}
|
||||
|
@ -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()
|
||||
//}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
//}
|
@ -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()
|
||||
//}
|
@ -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()
|
||||
// }
|
||||
//}
|
@ -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()
|
||||
}
|
@ -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()
|
||||
//}
|
@ -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
|
||||
}
|
34
Mastodon/Scene/Notification/NotificationListItem.swift
Normal file
34
Mastodon/Scene/Notification/NotificationListItem.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -58,7 +58,7 @@ extension NotificationTimelineViewController: DataSourceProvider {
|
||||
}
|
||||
case .filteredNotificationsInfo(let policy):
|
||||
return DataSourceItem.notificationBanner(policy: policy)
|
||||
case .bottomLoader, .feedLoader:
|
||||
case .bottomLoader, .middleLoader:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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) }
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user