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>
</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>
</plist>

View file

@ -2,21 +2,37 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>timeline-new-posts %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@timelineNewPosts@</string>
<key>timelineNewPosts</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string>%lld new post</string>
<key>other</key>
<string>%lld new posts</string>
</dict>
</dict>
<key>timeline-new-posts %lld</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@timelineNewPosts@</string>
<key>timelineNewPosts</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>lld</string>
<key>one</key>
<string>%lld new post</string>
<key>other</key>
<string>%lld new posts</string>
</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>
</plist>

View file

@ -18,5 +18,21 @@
<string>%lld nuevas publicaciones</string>
</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>
</plist>

View file

@ -18,5 +18,21 @@
<string>%lld nuovi post</string>
</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>
</plist>

View file

@ -18,5 +18,21 @@
<string>%lld 新しい投稿</string>
</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>
</plist>

View file

@ -18,5 +18,21 @@
<string>%lld nieuwe posts</string>
</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>
</plist>

View file

@ -18,5 +18,21 @@
<string>%lld yeni gönderiler</string>
</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>
</plist>

View file

@ -18,5 +18,21 @@
<string>%lld 个新嘟文</string>
</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>
</plist>

View file

@ -14,16 +14,4 @@ public struct Notification: Decodable, Identifiable {
public var supportedType: NotificationType? {
.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,56 +11,81 @@ struct NotificationRowView: View {
@EnvironmentObject private var routerPath: RouterPath
@Environment(\.redactionReasons) private var reasons
let notification: Models.Notification
let notification: ConsolidatedNotification
var body: some View {
if let type = notification.supportedType {
HStack(alignment: .top, spacing: 8) {
makeAvatarView(type: type)
VStack(alignment: .leading, spacing: 2) {
makeMainLabel(type: type)
makeContent(type: type)
if type == .follow_request,
currentAccount.followRequests.map(\.id).contains(notification.account.id)
{
FollowRequestButtons(account: notification.account)
}
HStack(alignment: .top, spacing: 8) {
if notification.accounts.count == 1 {
makeAvatarView(type: notification.type)
} else {
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])
}
}
} else {
EmptyView()
}
}
private func makeAvatarView(type: Models.Notification.NotificationType) -> some View {
ZStack(alignment: .topLeading) {
AvatarView(url: notification.account.avatar)
ZStack(alignment: .center) {
Circle()
.strokeBorder(Color.white, lineWidth: 1)
.background(Circle().foregroundColor(theme.tintColor))
.frame(width: 24, height: 24)
Image(systemName: type.iconName())
.resizable()
.frame(width: 12, height: 12)
.foregroundColor(.white)
}
.offset(x: -8, y: -8)
AvatarView(url: notification.accounts[0].avatar)
makeNotificationIconView(type: type)
.offset(x: -8, y: -8)
}
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account))
routerPath.navigate(to: .accountDetailWithAccount(account: notification.accounts[0]))
}
}
private func makeNotificationIconView(type: Models.Notification.NotificationType) -> some View {
ZStack(alignment: .center) {
Circle()
.strokeBorder(Color.white, lineWidth: 1)
.background(Circle().foregroundColor(theme.tintColor))
.frame(width: 24, height: 24)
Image(systemName: type.iconName())
.resizable()
.frame(width: 12, height: 12)
.foregroundColor(.white)
}
}
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) {
EmojiTextApp(.init(stringValue: notification.account.safeDisplayName),
emojis: notification.account.emojis,
EmojiTextApp(.init(stringValue: notification.accounts[0].safeDisplayName),
emojis: notification.accounts[0].emojis,
append: {
Text(" ") +
(notification.accounts.count > 1
? Text("notifications-others-count \(notification.accounts.count - 1)")
.font(.scaledSubheadline)
.fontWeight(.regular)
: Text(" ")) +
Text(type.label())
.font(.scaledSubheadline)
.fontWeight(.regular) +
@ -75,8 +100,7 @@ struct NotificationRowView: View {
})
.font(.scaledSubheadline)
.fontWeight(.semibold)
.lineLimit(1)
if let status = notification.status, notification.supportedType == .mention {
if let status = notification.status, notification.type == .mention {
Group {
Text("")
Text(Image(systemName: status.visibility.iconName))
@ -90,7 +114,7 @@ struct NotificationRowView: View {
}
.contentShape(Rectangle())
.onTapGesture {
routerPath.navigate(to: .accountDetailWithAccount(account: notification.account))
routerPath.navigate(to: .accountDetailWithAccount(account: notification.accounts[0]))
}
}
@ -109,13 +133,13 @@ struct NotificationRowView: View {
}
} else {
Group {
Text("@\(notification.account.acct)")
Text("@\(notification.accounts[0].acct)")
.font(.scaledCallout)
.foregroundColor(.gray)
if type == .follow {
EmojiTextApp(notification.account.note,
emojis: notification.account.emojis)
EmojiTextApp(notification.accounts[0].note,
emojis: notification.accounts[0].emojis)
.lineLimit(3)
.font(.scaledCallout)
.foregroundColor(.gray)
@ -126,7 +150,7 @@ struct NotificationRowView: View {
}
.contentShape(Rectangle())
.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 {
switch viewModel.state {
case .loading:
ForEach(Models.Notification.placeholders()) { notification in
ForEach(ConsolidatedNotification.placeholders()) { notification in
NotificationRowView(notification: notification)
.redacted(reason: .placeholder)
.padding(.leading, .layoutPadding + 4)
@ -100,15 +100,13 @@ public struct NotificationsListView: View {
message: "notifications.empty.message")
} else {
ForEach(notifications) { notification in
if notification.supportedType != nil {
NotificationRowView(notification: notification)
.padding(.leading, .layoutPadding + 4)
.padding(.trailing, .layoutPadding)
.padding(.top, 6)
.padding(.bottom, 2)
Divider()
.padding(.vertical, .dividerPadding)
}
NotificationRowView(notification: notification)
.padding(.leading, .layoutPadding + 4)
.padding(.trailing, .layoutPadding)
.padding(.top, 6)
.padding(.bottom, 2)
Divider()
.padding(.vertical, .dividerPadding)
}
}

View file

@ -11,7 +11,7 @@ class NotificationsViewModel: ObservableObject {
}
case loading
case display(notifications: [Models.Notification], nextPageState: State.PagingState)
case display(notifications: [ConsolidatedNotification], nextPageState: State.PagingState)
case error(error: Error)
}
@ -23,7 +23,7 @@ class NotificationsViewModel: ObservableObject {
var client: Client? {
didSet {
if oldValue != client {
notifications = []
consolidatedNotifications = []
}
}
}
@ -32,7 +32,7 @@ class NotificationsViewModel: ObservableObject {
@Published var selectedType: Models.Notification.NotificationType? {
didSet {
if oldValue != selectedType {
notifications = []
consolidatedNotifications = []
Task {
await fetchNotifications()
}
@ -49,32 +49,34 @@ class NotificationsViewModel: ObservableObject {
return nil
}
private var notifications: [Models.Notification] = []
private var consolidatedNotifications: [ConsolidatedNotification] = []
func fetchNotifications() async {
guard let client else { return }
do {
var nextPageState: State.PagingState = .hasNextPage
if notifications.isEmpty {
if consolidatedNotifications.isEmpty {
state = .loading
notifications = try await client.get(endpoint: Notifications.notifications(sinceId: nil,
maxId: nil,
types: queryTypes))
let notifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(sinceId: nil,
maxId: nil,
types: queryTypes))
consolidatedNotifications = notifications.consolidated()
nextPageState = notifications.count < 15 ? .none : .hasNextPage
} else if let first = notifications.first {
} else if let first = consolidatedNotifications.first {
var newNotifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(sinceId: first.id,
maxId: nil,
types: queryTypes))
nextPageState = notifications.count < 15 ? .none : .hasNextPage
nextPageState = consolidatedNotifications.notificationCount < 15 ? .none : .hasNextPage
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 {
state = .display(notifications: notifications,
nextPageState: notifications.isEmpty ? .none : nextPageState)
state = .display(notifications: consolidatedNotifications,
nextPageState: consolidatedNotifications.isEmpty ? .none : nextPageState)
}
} catch {
state = .error(error: error)
@ -84,14 +86,14 @@ class NotificationsViewModel: ObservableObject {
func fetchNextPage() async {
guard let client else { return }
do {
guard let lastId = notifications.last?.id else { return }
state = .display(notifications: notifications, nextPageState: .loadingNextPage)
guard let lastId = consolidatedNotifications.last?.id else { return }
state = .display(notifications: consolidatedNotifications, nextPageState: .loadingNextPage)
let newNotifications: [Models.Notification] =
try await client.get(endpoint: Notifications.notifications(sinceId: nil,
maxId: lastId,
types: queryTypes))
notifications.append(contentsOf: newNotifications)
state = .display(notifications: notifications, nextPageState: newNotifications.count < 15 ? .none : .hasNextPage)
consolidatedNotifications.append(contentsOf: newNotifications.consolidated())
state = .display(notifications: consolidatedNotifications, nextPageState: newNotifications.count < 15 ? .none : .hasNextPage)
} catch {
state = .error(error: error)
}
@ -106,14 +108,77 @@ class NotificationsViewModel: ObservableObject {
func handleEvent(event: any StreamEvent) {
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 {
notifications.insert(event.notification, at: 0)
consolidatedNotifications.insert(contentsOf: [event.notification].consolidated(),
at: 0)
} 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) }
}
}