Grouped notifications
This commit is contained in:
parent
5320597963
commit
bee71071c2
|
@ -5,24 +5,43 @@ import SwiftUI
|
||||||
struct NotificationRow: View {
|
struct NotificationRow: View {
|
||||||
@EnvironmentObject private var navigator: Navigator
|
@EnvironmentObject private var navigator: Navigator
|
||||||
|
|
||||||
var notif: Notification = .placeholder()
|
@State private var multiPeopleSheet: Bool = false
|
||||||
|
|
||||||
|
var notif: GroupedNotification
|
||||||
var showIcon: Bool = true
|
var showIcon: Bool = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
HStack(spacing: 5) {
|
HStack(spacing: 5) {
|
||||||
ProfilePicture(url: notif.account.avatar, size: 60)
|
if let acc = notif.accounts.first {
|
||||||
.padding(.trailing)
|
if notif.accounts.count == 1 {
|
||||||
.overlay(alignment: .bottomTrailing) {
|
ProfilePicture(url: acc.avatar, size: 60)
|
||||||
if showIcon {
|
.padding(.trailing)
|
||||||
notifIcon()
|
.overlay(alignment: .bottomTrailing) {
|
||||||
.offset(x: -5, y: 5)
|
if showIcon {
|
||||||
}
|
notifIcon()
|
||||||
}
|
.offset(x: -5, y: 5)
|
||||||
.padding(.horizontal, 10)
|
}
|
||||||
.onTapGesture {
|
}
|
||||||
navigator.navigate(to: .account(acc: notif.account))
|
.padding(.horizontal, 10)
|
||||||
|
.onTapGesture {
|
||||||
|
navigator.navigate(to: .account(acc: acc))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accountCount()
|
||||||
|
.padding(.trailing)
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
if showIcon {
|
||||||
|
notifIcon()
|
||||||
|
.offset(x: -5, y: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.onTapGesture {
|
||||||
|
multiPeopleSheet.toggle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(localizedString())
|
Text(localizedString())
|
||||||
|
@ -37,33 +56,89 @@ struct NotificationRow: View {
|
||||||
.foregroundStyle(Color.gray)
|
.foregroundStyle(Color.gray)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
|
||||||
if notif.status!.mediaAttachments.count > 0 {
|
|
||||||
Label("activity.status.attachments-\(notif.status!.mediaAttachments.count)", systemImage: "photo.on.rectangle.angled")
|
HStack(spacing: 7.5) {
|
||||||
|
if notif.status!.mediaAttachments.count > 0 {
|
||||||
|
Label("activity.status.attachments-\(notif.status!.mediaAttachments.count)", systemImage: notif.status!.mediaAttachments.count > 1 ? "photo.on.rectangle.angled" : "photo")
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.gray)
|
||||||
|
.lineLimit(1, reservesSpace: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(notif.notifications[0].createdAt.relativeFormatted)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Color.gray)
|
.foregroundStyle(Color.gray)
|
||||||
.lineLimit(1, reservesSpace: false)
|
.lineLimit(1, reservesSpace: false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
TextEmoji(notif.account.note, emojis: notif.account.emojis)
|
if let acc = notif.accounts.first {
|
||||||
.multilineTextAlignment(.leading)
|
TextEmoji(acc.note, emojis: acc.emojis)
|
||||||
.font(.caption)
|
.multilineTextAlignment(.leading)
|
||||||
.foregroundStyle(Color.gray)
|
.font(.caption)
|
||||||
.lineLimit(3, reservesSpace: true)
|
.foregroundStyle(Color.gray)
|
||||||
|
.lineLimit(3, reservesSpace: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
navigator.navigate(to: notif.status == nil ? .account(acc: notif.account) : .post(status: notif.status!))
|
navigator.navigate(to: notif.status == nil ? .account(acc: notif.accounts.first!) : .post(status: notif.status!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
.sheet(isPresented: $multiPeopleSheet) {
|
||||||
|
users
|
||||||
|
.presentationDetents([.height(200), .medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationCornerRadius(25)
|
||||||
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var users: some View {
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(alignment: .listRowSeparatorLeading) {
|
||||||
|
ForEach(notif.accounts) { acc in
|
||||||
|
HStack {
|
||||||
|
ProfilePicture(url: acc.avatar, size: 60)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(acc.displayName ?? acc.username)
|
||||||
|
.font(.body.bold())
|
||||||
|
Text("@\(acc.acct)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.gray)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
.onTapGesture {
|
||||||
|
multiPeopleSheet.toggle()
|
||||||
|
navigator.navigate(to: .account(acc: acc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func localizedString() -> LocalizedStringKey {
|
private func localizedString() -> LocalizedStringKey {
|
||||||
let nameStr = "@\(notif.account.username)"
|
var nameStr: String = ""
|
||||||
switch (notif.supportedType) {
|
if notif.accounts.count > 1, let acc = notif.accounts.first {
|
||||||
|
nameStr = String(localized: "activity.group.\("@" + acc.username)-\(notif.accounts.count - 1)")
|
||||||
|
} else if let acc = notif.accounts.first {
|
||||||
|
nameStr = "@\(acc.username)"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (notif.type) {
|
||||||
case .favourite:
|
case .favourite:
|
||||||
return "activity.favorite.\(nameStr)"
|
return "activity.favorite.\(nameStr)"
|
||||||
case .follow:
|
case .follow:
|
||||||
|
@ -74,13 +149,15 @@ struct NotificationRow: View {
|
||||||
return "activity.reblogged.\(nameStr)"
|
return "activity.reblogged.\(nameStr)"
|
||||||
case .status:
|
case .status:
|
||||||
return "activity.status.\(nameStr)"
|
return "activity.status.\(nameStr)"
|
||||||
|
case .update:
|
||||||
|
return "activity.update.\(nameStr)"
|
||||||
default:
|
default:
|
||||||
return "activity.unknown" // follow requests & polls
|
return "activity.unknown" // follow requests & polls
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifColor() -> Color {
|
private func notifColor() -> Color {
|
||||||
switch (notif.supportedType) {
|
switch (notif.type) {
|
||||||
case .favourite:
|
case .favourite:
|
||||||
return Color.red
|
return Color.red
|
||||||
case .follow:
|
case .follow:
|
||||||
|
@ -89,7 +166,7 @@ struct NotificationRow: View {
|
||||||
return Color.blue
|
return Color.blue
|
||||||
case .reblog:
|
case .reblog:
|
||||||
return Color.orange
|
return Color.orange
|
||||||
case .status:
|
case .status, .update: // update and post are techn. the same
|
||||||
return Color.yellow
|
return Color.yellow
|
||||||
default:
|
default:
|
||||||
return Color.gray
|
return Color.gray
|
||||||
|
@ -101,7 +178,7 @@ struct NotificationRow: View {
|
||||||
let size: CGFloat = 60.0 / 4.0
|
let size: CGFloat = 60.0 / 4.0
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
switch (notif.supportedType) {
|
switch (notif.type) {
|
||||||
case .favourite:
|
case .favourite:
|
||||||
Image(systemName: "heart.fill")
|
Image(systemName: "heart.fill")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
@ -127,6 +204,11 @@ struct NotificationRow: View {
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
|
case .update:
|
||||||
|
Image(systemName: "pencil.and.scribble")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: size, height: size)
|
||||||
default:
|
default:
|
||||||
Image(systemName: "questionmark")
|
Image(systemName: "questionmark")
|
||||||
.resizable()
|
.resizable()
|
||||||
|
@ -144,8 +226,25 @@ struct NotificationRow: View {
|
||||||
}
|
}
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@ViewBuilder
|
||||||
#Preview {
|
private func accountCount() -> some View {
|
||||||
NotificationRow()
|
ZStack {
|
||||||
|
Text(String("+\(notif.accounts.count - 1)"))
|
||||||
|
.font(.body)
|
||||||
|
.scaledToFit()
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
}
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.padding(7)
|
||||||
|
.background(Color.gray)
|
||||||
|
.clipShape(.circle)
|
||||||
|
.overlay {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.appBackground, lineWidth: 3)
|
||||||
|
}
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,3 +28,76 @@ public struct Notification: Decodable, Identifiable, Equatable {
|
||||||
|
|
||||||
extension Notification: Sendable {}
|
extension Notification: Sendable {}
|
||||||
extension Notification.NotificationType: Sendable {}
|
extension Notification.NotificationType: Sendable {}
|
||||||
|
|
||||||
|
extension [Notification] {
|
||||||
|
public func toGrouped() -> [GroupedNotification] {
|
||||||
|
var groupedNotifications: [GroupedNotification] = []
|
||||||
|
|
||||||
|
for notification in self {
|
||||||
|
if let existingIndex = groupedNotifications.firstIndex(where: { $0.type == notification.supportedType && $0.timeDifference(with: notification) >= GroupedNotification.groupHours && $0.status == notification.status }) {
|
||||||
|
// If a group with the same type exists, add the notification to that group
|
||||||
|
groupedNotifications[existingIndex].notifications.append(notification)
|
||||||
|
groupedNotifications[existingIndex].accounts.append(notification.account)
|
||||||
|
groupedNotifications[existingIndex].accounts = groupedNotifications[existingIndex].accounts.uniqued()
|
||||||
|
} else {
|
||||||
|
// If no group with the same type exists, create a new group
|
||||||
|
groupedNotifications.append(GroupedNotification(notifications: [notification], type: notification.supportedType ?? .favourite))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groupedNotifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GroupedNotification: Identifiable, Hashable {
|
||||||
|
public var id: String? { notifications.first?.id }
|
||||||
|
public var notifications: [Notification]
|
||||||
|
public let type: Notification.NotificationType
|
||||||
|
public var accounts: [Account]
|
||||||
|
public let status: Status?
|
||||||
|
|
||||||
|
public static let groupHours: Int = -5 // all notifications 5 hours away from first
|
||||||
|
|
||||||
|
init(notifications: [Notification], type: Notification.NotificationType, accounts: [Account], status: Status?) {
|
||||||
|
self.notifications = notifications
|
||||||
|
self.type = type
|
||||||
|
self.accounts = accounts
|
||||||
|
self.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
init(notifications: [Notification], type: Notification.NotificationType) {
|
||||||
|
if let firstNotif = notifications.first {
|
||||||
|
if let maxType = Calendar.current.date(byAdding: .hour, value: GroupedNotification.groupHours, to: firstNotif.createdAt.asDate) {
|
||||||
|
let filtered = notifications.filter({ $0.supportedType == type && $0.createdAt.asDate >= maxType })
|
||||||
|
let timed = filtered.sorted(by: { $0.createdAt.asDate > $1.createdAt.asDate })
|
||||||
|
|
||||||
|
self.notifications = timed
|
||||||
|
self.accounts = timed.map({ $0.account })
|
||||||
|
self.status = firstNotif.status
|
||||||
|
} else {
|
||||||
|
self.notifications = []
|
||||||
|
self.accounts = []
|
||||||
|
self.status = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.notifications = []
|
||||||
|
self.accounts = []
|
||||||
|
self.status = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeDifference(with notification: Notification) -> Int {
|
||||||
|
guard let firstNotificationDate = notifications.first?.createdAt.asDate else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeDifference = Calendar.current.dateComponents([.hour, .minute], from: firstNotificationDate, to: notification.createdAt.asDate)
|
||||||
|
return timeDifference.hour ?? 0 // Get the time difference in hours
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@ struct NotificationsView: View {
|
||||||
@Environment(AccountManager.self) private var accountManager
|
@Environment(AccountManager.self) private var accountManager
|
||||||
|
|
||||||
@State private var navigator: Navigator = Navigator()
|
@State private var navigator: Navigator = Navigator()
|
||||||
@State private var notifications: [Notification] = []
|
@State private var notifications: [GroupedNotification] = []
|
||||||
@State private var loadingNotifs: Bool = true
|
@State private var loadingNotifs: Bool = true
|
||||||
@State private var lastId: Int? = nil
|
@State private var lastId: Int? = nil
|
||||||
private let notifLimit = 50
|
private let notifLimit = 50
|
||||||
|
@ -110,15 +110,15 @@ struct NotificationsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
var notifs: [Notification] = try await client.get(endpoint: Notifications.notifications(minId: notifications.last?.id, maxId: nil, types: nil, limit: lastId != nil ? notifLimit : 30))
|
var notifs: [Notification] = try await client.get(endpoint: Notifications.notifications(minId: nil, maxId: notifications.last?.id, types: nil, limit: lastId != nil ? notifLimit : 30))
|
||||||
guard !notifs.isEmpty else { return }
|
guard !notifs.isEmpty else { return }
|
||||||
|
|
||||||
notifs = notifs.filter({ $0.supportedType != .mention && $0.status?.visibility != .direct })
|
notifs = notifs.filter({ $0.supportedType != .mention && $0.status?.visibility != .direct })
|
||||||
|
|
||||||
if notifications.isEmpty {
|
if notifications.isEmpty {
|
||||||
notifications = notifs
|
notifications = notifs.toGrouped()
|
||||||
} else {
|
} else {
|
||||||
notifications.append(contentsOf: notifs)
|
notifications.append(contentsOf: notifs.toGrouped())
|
||||||
}
|
}
|
||||||
|
|
||||||
await getBadge()
|
await getBadge()
|
||||||
|
|
Loading…
Reference in New Issue