Consolidated notifications (#443) close #231

* Group favorite and boost notifications

* Group notifications per page, not globally
This commit is contained in:
Jérôme Danthinne 2023-01-27 16:58:04 +01:00 committed by GitHub
parent 1824721a57
commit bec9ab8792
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 302 additions and 99 deletions

View file

@ -18,5 +18,21 @@
<string>%lld neue Posts</string> <string>%lld neue Posts</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld new posts</string> <string>%lld new posts</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld nuevas publicaciones</string> <string>%lld nuevas publicaciones</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld nuovi post</string> <string>%lld nuovi post</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld 新しい投稿</string> <string>%lld 新しい投稿</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld nieuwe posts</string> <string>%lld nieuwe posts</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld yeni gönderiler</string> <string>%lld yeni gönderiler</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -18,5 +18,21 @@
<string>%lld 个新嘟文</string> <string>%lld 个新嘟文</string>
</dict> </dict>
</dict> </dict>
<key>notifications-others-count %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@noficationsOthersCount@</string>
<key>noficationsOthersCount</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string> and %lld other </string>
<key>other</key>
<string> and %lld others </string>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View file

@ -14,16 +14,4 @@ public struct Notification: Decodable, Identifiable {
public var supportedType: NotificationType? { public var supportedType: NotificationType? {
.init(rawValue: type) .init(rawValue: type)
} }
public static func placeholder() -> Notification {
.init(id: UUID().uuidString,
type: NotificationType.favourite.rawValue,
createdAt: "2022-12-16T10:20:54.000Z",
account: .placeholder(),
status: .placeholder())
}
public static func placeholders() -> [Notification] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
}
} }

View file

@ -11,30 +11,42 @@ struct NotificationRowView: View {
@EnvironmentObject private var routerPath: RouterPath @EnvironmentObject private var routerPath: RouterPath
@Environment(\.redactionReasons) private var reasons @Environment(\.redactionReasons) private var reasons
let notification: Models.Notification let notification: ConsolidatedNotification
var body: some View { var body: some View {
if let type = notification.supportedType {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
makeAvatarView(type: type) if notification.accounts.count == 1 {
VStack(alignment: .leading, spacing: 2) { makeAvatarView(type: notification.type)
makeMainLabel(type: type)
makeContent(type: type)
if type == .follow_request,
currentAccount.followRequests.map(\.id).contains(notification.account.id)
{
FollowRequestButtons(account: notification.account)
}
}
}
} else { } else {
EmptyView() makeNotificationIconView(type: notification.type)
.frame(width: AvatarView.Size.status.size.width,
height: AvatarView.Size.status.size.height)
}
VStack(alignment: .leading, spacing: 2) {
makeMainLabel(type: notification.type)
makeContent(type: notification.type)
if notification.type == .follow_request,
currentAccount.followRequests.map(\.id).contains(notification.accounts[0].id)
{
FollowRequestButtons(account: notification.accounts[0])
}
}
} }
} }
private func makeAvatarView(type: Models.Notification.NotificationType) -> some View { private func makeAvatarView(type: Models.Notification.NotificationType) -> some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
AvatarView(url: notification.account.avatar) AvatarView(url: notification.accounts[0].avatar)
makeNotificationIconView(type: type)
.offset(x: -8, y: -8)
}
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.accounts[0]))
}
}
private func makeNotificationIconView(type: Models.Notification.NotificationType) -> some View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
Circle() Circle()
.strokeBorder(Color.white, lineWidth: 1) .strokeBorder(Color.white, lineWidth: 1)
@ -46,21 +58,34 @@ struct NotificationRowView: View {
.frame(width: 12, height: 12) .frame(width: 12, height: 12)
.foregroundColor(.white) .foregroundColor(.white)
} }
.offset(x: -8, y: -8)
}
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account))
}
} }
private func makeMainLabel(type: Models.Notification.NotificationType) -> some View { private func makeMainLabel(type: Models.Notification.NotificationType) -> some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 4) {
if notification.accounts.count > 1 {
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 8) {
ForEach(notification.accounts) { account in
AvatarView(url: account.avatar)
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: account))
}
}
}
.padding(.leading, 1)
.frame(height: AvatarView.Size.status.size.height + 2)
}.offset(y: -1)
}
HStack(spacing: 0) { HStack(spacing: 0) {
EmojiTextApp(.init(stringValue: notification.account.safeDisplayName), EmojiTextApp(.init(stringValue: notification.accounts[0].safeDisplayName),
emojis: notification.account.emojis, emojis: notification.accounts[0].emojis,
append: { append: {
Text(" ") + (notification.accounts.count > 1
? Text("notifications-others-count \(notification.accounts.count - 1)")
.font(.scaledSubheadline)
.fontWeight(.regular)
: Text(" ")) +
Text(type.label()) Text(type.label())
.font(.scaledSubheadline) .font(.scaledSubheadline)
.fontWeight(.regular) + .fontWeight(.regular) +
@ -75,8 +100,7 @@ struct NotificationRowView: View {
}) })
.font(.scaledSubheadline) .font(.scaledSubheadline)
.fontWeight(.semibold) .fontWeight(.semibold)
.lineLimit(1) if let status = notification.status, notification.type == .mention {
if let status = notification.status, notification.supportedType == .mention {
Group { Group {
Text("") Text("")
Text(Image(systemName: status.visibility.iconName)) Text(Image(systemName: status.visibility.iconName))
@ -90,7 +114,7 @@ struct NotificationRowView: View {
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account)) routerPath.navigate(to: .accountDetailWithAccount(account: notification.accounts[0]))
} }
} }
@ -109,13 +133,13 @@ struct NotificationRowView: View {
} }
} else { } else {
Group { Group {
Text("@\(notification.account.acct)") Text("@\(notification.accounts[0].acct)")
.font(.scaledCallout) .font(.scaledCallout)
.foregroundColor(.gray) .foregroundColor(.gray)
if type == .follow { if type == .follow {
EmojiTextApp(notification.account.note, EmojiTextApp(notification.accounts[0].note,
emojis: notification.account.emojis) emojis: notification.accounts[0].emojis)
.lineLimit(3) .lineLimit(3)
.font(.scaledCallout) .font(.scaledCallout)
.foregroundColor(.gray) .foregroundColor(.gray)
@ -126,7 +150,7 @@ struct NotificationRowView: View {
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account)) routerPath.navigate(to: .accountDetailWithAccount(account: notification.accounts[0]))
} }
} }
} }

View file

@ -81,7 +81,7 @@ public struct NotificationsListView: View {
private var notificationsView: some View { private var notificationsView: some View {
switch viewModel.state { switch viewModel.state {
case .loading: case .loading:
ForEach(Models.Notification.placeholders()) { notification in ForEach(ConsolidatedNotification.placeholders()) { notification in
NotificationRowView(notification: notification) NotificationRowView(notification: notification)
.redacted(reason: .placeholder) .redacted(reason: .placeholder)
.padding(.leading, .layoutPadding + 4) .padding(.leading, .layoutPadding + 4)
@ -100,7 +100,6 @@ public struct NotificationsListView: View {
message: "notifications.empty.message") message: "notifications.empty.message")
} else { } else {
ForEach(notifications) { notification in ForEach(notifications) { notification in
if notification.supportedType != nil {
NotificationRowView(notification: notification) NotificationRowView(notification: notification)
.padding(.leading, .layoutPadding + 4) .padding(.leading, .layoutPadding + 4)
.padding(.trailing, .layoutPadding) .padding(.trailing, .layoutPadding)
@ -110,7 +109,6 @@ public struct NotificationsListView: View {
.padding(.vertical, .dividerPadding) .padding(.vertical, .dividerPadding)
} }
} }
}
switch nextPageState { switch nextPageState {
case .none: case .none:

View file

@ -11,7 +11,7 @@ class NotificationsViewModel: ObservableObject {
} }
case loading case loading
case display(notifications: [Models.Notification], nextPageState: State.PagingState) case display(notifications: [ConsolidatedNotification], nextPageState: State.PagingState)
case error(error: Error) case error(error: Error)
} }
@ -23,7 +23,7 @@ class NotificationsViewModel: ObservableObject {
var client: Client? { var client: Client? {
didSet { didSet {
if oldValue != client { if oldValue != client {
notifications = [] consolidatedNotifications = []
} }
} }
} }
@ -32,7 +32,7 @@ class NotificationsViewModel: ObservableObject {
@Published var selectedType: Models.Notification.NotificationType? { @Published var selectedType: Models.Notification.NotificationType? {
didSet { didSet {
if oldValue != selectedType { if oldValue != selectedType {
notifications = [] consolidatedNotifications = []
Task { Task {
await fetchNotifications() await fetchNotifications()
} }
@ -49,32 +49,34 @@ class NotificationsViewModel: ObservableObject {
return nil return nil
} }
private var notifications: [Models.Notification] = [] private var consolidatedNotifications: [ConsolidatedNotification] = []
func fetchNotifications() async { func fetchNotifications() async {
guard let client else { return } guard let client else { return }
do { do {
var nextPageState: State.PagingState = .hasNextPage var nextPageState: State.PagingState = .hasNextPage
if notifications.isEmpty { if consolidatedNotifications.isEmpty {
state = .loading state = .loading
notifications = try await client.get(endpoint: Notifications.notifications(sinceId: nil, let notifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(sinceId: nil,
maxId: nil, maxId: nil,
types: queryTypes)) types: queryTypes))
consolidatedNotifications = notifications.consolidated()
nextPageState = notifications.count < 15 ? .none : .hasNextPage nextPageState = notifications.count < 15 ? .none : .hasNextPage
} else if let first = notifications.first { } else if let first = consolidatedNotifications.first {
var newNotifications: [Models.Notification] = var newNotifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(sinceId: first.id, try await client.get(endpoint: Notifications.notifications(sinceId: first.id,
maxId: nil, maxId: nil,
types: queryTypes)) types: queryTypes))
nextPageState = notifications.count < 15 ? .none : .hasNextPage nextPageState = consolidatedNotifications.notificationCount < 15 ? .none : .hasNextPage
newNotifications = newNotifications.filter { notification in newNotifications = newNotifications.filter { notification in
!notifications.contains(where: { $0.id == notification.id }) !consolidatedNotifications.contains(where: { $0.id == notification.id })
} }
notifications.insert(contentsOf: newNotifications, at: 0) consolidatedNotifications.insert(contentsOf: newNotifications.consolidated(), at: 0)
} }
withAnimation { withAnimation {
state = .display(notifications: notifications, state = .display(notifications: consolidatedNotifications,
nextPageState: notifications.isEmpty ? .none : nextPageState) nextPageState: consolidatedNotifications.isEmpty ? .none : nextPageState)
} }
} catch { } catch {
state = .error(error: error) state = .error(error: error)
@ -84,14 +86,14 @@ class NotificationsViewModel: ObservableObject {
func fetchNextPage() async { func fetchNextPage() async {
guard let client else { return } guard let client else { return }
do { do {
guard let lastId = notifications.last?.id else { return } guard let lastId = consolidatedNotifications.last?.id else { return }
state = .display(notifications: notifications, nextPageState: .loadingNextPage) state = .display(notifications: consolidatedNotifications, nextPageState: .loadingNextPage)
let newNotifications: [Models.Notification] = let newNotifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(sinceId: nil, try await client.get(endpoint: Notifications.notifications(sinceId: nil,
maxId: lastId, maxId: lastId,
types: queryTypes)) types: queryTypes))
notifications.append(contentsOf: newNotifications) consolidatedNotifications.append(contentsOf: newNotifications.consolidated())
state = .display(notifications: notifications, nextPageState: newNotifications.count < 15 ? .none : .hasNextPage) state = .display(notifications: consolidatedNotifications, nextPageState: newNotifications.count < 15 ? .none : .hasNextPage)
} catch { } catch {
state = .error(error: error) state = .error(error: error)
} }
@ -106,14 +108,77 @@ class NotificationsViewModel: ObservableObject {
func handleEvent(event: any StreamEvent) { func handleEvent(event: any StreamEvent) {
if let event = event as? StreamEventNotification, if let event = event as? StreamEventNotification,
!notifications.contains(where: { $0.id == event.notification.id }) !consolidatedNotifications.contains(where: { $0.id == event.notification.id })
{ {
if let selectedType, event.notification.type == selectedType.rawValue { if let selectedType, event.notification.type == selectedType.rawValue {
notifications.insert(event.notification, at: 0) consolidatedNotifications.insert(contentsOf: [event.notification].consolidated(),
at: 0)
} else if selectedType == nil { } else if selectedType == nil {
notifications.insert(event.notification, at: 0) consolidatedNotifications.insert(contentsOf: [event.notification].consolidated(),
at: 0)
} }
state = .display(notifications: notifications, nextPageState: .hasNextPage) state = .display(notifications: consolidatedNotifications, nextPageState: .hasNextPage)
} }
} }
} }
struct ConsolidatedNotification: Identifiable {
let id: String
let type: Models.Notification.NotificationType
let createdAt: ServerDate
let accounts: [Account]
let status: Status?
static func placeholder() -> ConsolidatedNotification {
.init(id: UUID().uuidString,
type: .favourite,
createdAt: "2022-12-16T10:20:54.000Z",
accounts: [.placeholder()],
status: .placeholder())
}
static func placeholders() -> [ConsolidatedNotification] {
[.placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder(), .placeholder()]
}
}
extension Array where Element == Models.Notification {
func consolidated() -> [ConsolidatedNotification] {
Dictionary(grouping: self) { notification -> String? in
guard let supportedType = notification.supportedType else { return nil }
switch supportedType {
case .follow:
// Always group followers
return supportedType.rawValue
case .reblog, .favourite:
// Group boosts and favourites by status
return "\(supportedType.rawValue)-\(notification.status?.id ?? "")"
case .follow_request, .poll, .status, .update, .mention:
// Never group those
return notification.id
}
}
.values
.compactMap { notifications in
guard let notification = notifications.first,
let supportedType = notification.supportedType
else { return nil }
return ConsolidatedNotification(id: notification.id,
type: supportedType,
createdAt: notification.createdAt,
accounts: notifications.map(\.account),
status: notification.status)
}
.sorted {
$0.createdAt > $1.createdAt
}
}
}
extension Array where Element == ConsolidatedNotification {
var notificationCount: Int {
reduce(0) { $0 + ($1.accounts.isEmpty ? 1 : $1.accounts.count) }
}
}