Skip to content

Commit b0d928c

Browse files
committed
WIP Notifications
1 parent 53f9c2d commit b0d928c

10 files changed

+365
-91
lines changed

Packages/Features/Package.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ let package = Package(
5454
),
5555
.target(
5656
name: "NotificationsUI",
57-
dependencies: baseDeps
57+
dependencies: baseDeps + ["PostUI"]
5858
),
5959
.target(
6060
name: "DesignSystem",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import ATProtoKit
2+
import Foundation
3+
import Models
4+
import Network
5+
6+
@MainActor
7+
struct NotificationsGroup: Identifiable {
8+
let id: String
9+
let timestamp: Date
10+
let type: AppBskyLexicon.Notification.Notification.Reason
11+
let notifications: [AppBskyLexicon.Notification.Notification]
12+
let postItem: PostItem?
13+
14+
static func groupNotifications(
15+
client: BSkyClient,
16+
_ notifications: [AppBskyLexicon.Notification.Notification]
17+
) async -> [NotificationsGroup] {
18+
var groups: [NotificationsGroup] = []
19+
var groupedNotifications:
20+
[AppBskyLexicon.Notification.Notification.Reason: [String: [AppBskyLexicon.Notification
21+
.Notification]]] = [:]
22+
23+
// Sort notifications by date
24+
let sortedNotifications = notifications.sorted { $0.indexedAt > $1.indexedAt }
25+
26+
let subjectURI = sortedNotifications.filter { $0.notificationReason != .follow }.compactMap {
27+
$0.notificationReason == .reply ? $0.notificationURI : $0.reasonSubjectURI
28+
}
29+
var postItems: [PostItem] = []
30+
do {
31+
postItems = try await client.protoClient.getPosts(subjectURI).posts.map { $0.postItem }
32+
} catch {
33+
postItems = []
34+
}
35+
36+
for notification in sortedNotifications {
37+
let reason = notification.notificationReason
38+
39+
if reason.shouldGroup {
40+
// Group notifications by type and subject
41+
let key = notification.reasonSubjectURI ?? "general"
42+
groupedNotifications[reason, default: [:]][key, default: []].append(notification)
43+
} else {
44+
// Create individual groups for non-grouped notifications
45+
groups.append(
46+
NotificationsGroup(
47+
id: notification.notificationURI,
48+
timestamp: notification.indexedAt,
49+
type: reason,
50+
notifications: [notification],
51+
postItem: postItems.first(where: { $0.uri == notification.reasonSubjectURI })
52+
))
53+
}
54+
}
55+
56+
// Add grouped notifications
57+
for (reason, subjectGroups) in groupedNotifications {
58+
for (subjectURI, notifications) in subjectGroups {
59+
groups.append(
60+
NotificationsGroup(
61+
id: "\(reason)-\(subjectURI)-\(notifications[0].indexedAt.timeIntervalSince1970)",
62+
timestamp: notifications[0].indexedAt,
63+
type: reason,
64+
notifications: notifications,
65+
postItem: postItems.first(where: { $0.uri == subjectURI })
66+
))
67+
}
68+
}
69+
70+
// Sort all groups by timestamp
71+
return groups.sorted { $0.timestamp > $1.timestamp }
72+
}
73+
}
74+
75+
extension AppBskyLexicon.Notification.Notification.Reason {
76+
fileprivate var shouldGroup: Bool {
77+
switch self {
78+
case .like, .follow:
79+
return true
80+
case .reply, .repost, .mention, .quote, .starterpackjoined:
81+
return false
82+
}
83+
}
84+
}

Packages/Features/Sources/NotificationsUI/NotificationsListView.swift

+66-5
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import SwiftUI
88
public struct NotificationsListView: View {
99
@Environment(BSkyClient.self) private var client
1010

11-
@State private var notifications: [AppBskyLexicon.Notification.Notification] = []
11+
@State private var notificationsGroups: [NotificationsGroup] = []
12+
@State private var cursor: String?
1213

1314
public init() {}
1415

@@ -17,21 +18,81 @@ public struct NotificationsListView: View {
1718
List {
1819
HeaderView(title: "Notifications", showBack: false)
1920
.padding(.bottom)
20-
ForEach(notifications, id: \.notificationURI) { notification in
21-
Text(notification.notificationReason.rawValue)
21+
22+
ForEach(notificationsGroups) { group in
23+
Section {
24+
switch group.type {
25+
case .reply:
26+
SingleNotificationRow(
27+
notification: group.notifications[0],
28+
postItem: group.postItem,
29+
actionText: "replied to your post"
30+
)
31+
case .follow:
32+
GroupedNotificationRow(group: group) { count in
33+
count == 1 ? " followed you" : " and \(count - 1) others followed you"
34+
}
35+
case .like:
36+
GroupedNotificationRow(group: group) { count in
37+
count == 1 ? " liked your post" : " and \(count - 1) others liked your post"
38+
}
39+
case .repost:
40+
GroupedNotificationRow(group: group) { count in
41+
count == 1 ? " reposted your post" : " and \(count - 1) others reposted your post"
42+
}
43+
case .mention:
44+
SingleNotificationRow(
45+
notification: group.notifications[0],
46+
postItem: group.postItem,
47+
actionText: "mentioned you"
48+
)
49+
case .quote:
50+
SingleNotificationRow(
51+
notification: group.notifications[0],
52+
postItem: group.postItem,
53+
actionText: "quoted you"
54+
)
55+
case .starterpackjoined:
56+
EmptyView()
57+
}
58+
}
59+
}
60+
61+
if cursor != nil {
62+
ProgressView()
63+
.task {
64+
await fetchNotifications()
65+
}
2266
}
2367
}
2468
.listStyle(.plain)
2569
}
2670
.task {
71+
cursor = nil
72+
await fetchNotifications()
73+
}
74+
.refreshable {
75+
cursor = nil
2776
await fetchNotifications()
2877
}
2978
}
3079

3180
private func fetchNotifications() async {
3281
do {
33-
self.notifications = try await client.protoClient.listNotifications(priority: false)
34-
.notifications
82+
if let cursor {
83+
let response = try await client.protoClient.listNotifications(
84+
priority: false, cursor: cursor)
85+
self.notificationsGroups.append(
86+
contentsOf: await NotificationsGroup.groupNotifications(
87+
client: client, response.notifications)
88+
)
89+
self.cursor = response.cursor
90+
} else {
91+
let response = try await client.protoClient.listNotifications(priority: false)
92+
self.notificationsGroups = await NotificationsGroup.groupNotifications(
93+
client: client, response.notifications)
94+
self.cursor = response.cursor
95+
}
3596
} catch {
3697
print(error)
3798
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import ATProtoKit
2+
import PostUI
3+
import SwiftUI
4+
5+
struct GroupedNotificationRow: View {
6+
let group: NotificationsGroup
7+
let actionText: (Int) -> String // Closure to generate action text based on count
8+
9+
var body: some View {
10+
HStack {
11+
VStack(alignment: .leading) {
12+
HStack(spacing: -10) {
13+
ForEach(group.notifications.prefix(5), id: \.notificationURI) { notification in
14+
AsyncImage(url: notification.notificationAuthor.avatarImageURL) { image in
15+
image.resizable()
16+
} placeholder: {
17+
Color.gray
18+
}
19+
.frame(width: 40, height: 40)
20+
.clipShape(Circle())
21+
.overlay(Circle().stroke(Color.white, lineWidth: 2))
22+
}
23+
}
24+
.padding(.trailing, 8)
25+
if group.notifications.count == 1 {
26+
Text(
27+
group.notifications[0].notificationAuthor.displayName
28+
?? group.notifications[0].notificationAuthor.actorHandle
29+
)
30+
.fontWeight(.semibold)
31+
+ Text(actionText(1))
32+
.foregroundStyle(.secondary)
33+
} else {
34+
Text(
35+
group.notifications[0].notificationAuthor.displayName
36+
?? group.notifications[0].notificationAuthor.actorHandle
37+
)
38+
.fontWeight(.semibold)
39+
+ Text(actionText(group.notifications.count))
40+
.foregroundStyle(.secondary)
41+
}
42+
43+
if let post = group.postItem {
44+
VStack(alignment: .leading, spacing: 8) {
45+
PostRowBodyView(post: post)
46+
.foregroundStyle(.secondary)
47+
PostRowEmbedView(post: post)
48+
}
49+
.environment(\.isQuote, true)
50+
}
51+
}
52+
53+
Spacer()
54+
55+
Text(group.notifications[0].indexedAt.relativeFormatted)
56+
.foregroundStyle(.secondary)
57+
}
58+
.padding(.vertical, 8)
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import ATProtoKit
2+
import Models
3+
import PostUI
4+
import SwiftUI
5+
6+
struct SingleNotificationRow: View {
7+
let notification: AppBskyLexicon.Notification.Notification
8+
let postItem: PostItem?
9+
let actionText: String
10+
11+
var body: some View {
12+
HStack(alignment: .top) {
13+
AsyncImage(url: notification.notificationAuthor.avatarImageURL) { image in
14+
image.resizable()
15+
} placeholder: {
16+
Color.gray
17+
}
18+
.frame(width: 40, height: 40)
19+
.clipShape(Circle())
20+
21+
VStack(alignment: .leading) {
22+
Text(
23+
notification.notificationAuthor.displayName ?? notification.notificationAuthor.actorHandle
24+
)
25+
.fontWeight(.semibold)
26+
Text(actionText)
27+
.foregroundStyle(.secondary)
28+
29+
if let postItem {
30+
PostRowView(post: postItem)
31+
}
32+
}
33+
34+
Spacer()
35+
36+
Text(notification.indexedAt.relativeFormatted)
37+
.foregroundStyle(.secondary)
38+
}
39+
.padding(.vertical, 8)
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Models
2+
import SwiftUI
3+
4+
public struct PostRowActionsView: View {
5+
let post: PostItem
6+
7+
public init(post: PostItem) {
8+
self.post = post
9+
}
10+
11+
public var body: some View {
12+
HStack(alignment: .firstTextBaseline, spacing: 16) {
13+
Button(action: {}) {
14+
Label("\(post.replyCount)", systemImage: "bubble")
15+
}
16+
.buttonStyle(.plain)
17+
.foregroundStyle(
18+
LinearGradient(
19+
colors: [.indigo, .purple],
20+
startPoint: .top,
21+
endPoint: .bottom
22+
)
23+
)
24+
25+
Button(action: {}) {
26+
Label("\(post.repostCount)", systemImage: "quote.bubble")
27+
}
28+
.buttonStyle(.plain)
29+
.symbolVariant(post.isReposted ? .fill : .none)
30+
.foregroundStyle(
31+
LinearGradient(
32+
colors: [.purple, .indigo],
33+
startPoint: .top,
34+
endPoint: .bottom
35+
)
36+
)
37+
38+
Button(action: {}) {
39+
Label("\(post.likeCount)", systemImage: "heart")
40+
}
41+
.buttonStyle(.plain)
42+
.symbolVariant(post.isLiked ? .fill : .none)
43+
.foregroundStyle(
44+
LinearGradient(
45+
colors: [.red, .purple],
46+
startPoint: .topLeading,
47+
endPoint: .bottomTrailing
48+
)
49+
)
50+
51+
Spacer()
52+
53+
Button(action: {}) {
54+
Image(systemName: "ellipsis")
55+
}
56+
.buttonStyle(.plain)
57+
.foregroundStyle(
58+
LinearGradient(
59+
colors: [.indigo, .purple],
60+
startPoint: .leading,
61+
endPoint: .trailing
62+
)
63+
)
64+
}
65+
.buttonStyle(.plain)
66+
.labelStyle(.customSpacing(4))
67+
.font(.callout)
68+
.padding(.top, 8)
69+
.padding(.bottom, 16)
70+
}
71+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Models
2+
import SwiftUI
3+
4+
public struct PostRowBodyView: View {
5+
@Environment(\.isFocused) private var isFocused
6+
7+
let post: PostItem
8+
9+
public init(post: PostItem) {
10+
self.post = post
11+
}
12+
13+
public var body: some View {
14+
Text(post.content)
15+
.font(isFocused ? .system(size: UIFontMetrics.default.scaledValue(for: 20)) : .body)
16+
}
17+
}

Packages/Features/Sources/PostUI/Row/PostRowEmbedView.swift

+6-2
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@ import DesignSystem
33
import Models
44
import SwiftUI
55

6-
struct PostRowEmbedView: View {
6+
public struct PostRowEmbedView: View {
77
let post: PostItem
88

9-
var body: some View {
9+
public init(post: PostItem) {
10+
self.post = post
11+
}
12+
13+
public var body: some View {
1014
if let embed = post.embed {
1115
switch embed {
1216
case .embedImagesView(let images):

0 commit comments

Comments
 (0)