Skip to content

Commit

Permalink
Better layout of grouped notification avatar row
Browse files Browse the repository at this point in the history
Contributes to #399 [BUG] Multiple interactions do not collapse into a single notification
  • Loading branch information
whattherestimefor committed Feb 11, 2025
1 parent 3396fd1 commit bfee18e
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 77 deletions.
175 changes: 104 additions & 71 deletions Mastodon/In Progress New Layout and Datamodel/NotificationRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@ import SwiftUI

extension Mastodon.Entity.NotificationType {

func shouldShowIcon(grouped: Bool) -> Bool {
return iconSystemName(grouped: grouped) != nil
func shouldShowIcon(
grouped: Bool, visibility: Mastodon.Entity.Status.Visibility?
) -> Bool {
return iconSystemName(grouped: grouped, visibility: visibility) != nil
}

func iconSystemName(grouped: Bool = false) -> String? {
func iconSystemName(
grouped: Bool = false, visibility: Mastodon.Entity.Status.Visibility?
) -> String? {
switch self {
case .favourite:
return "star.fill"
Expand All @@ -40,7 +44,12 @@ extension Mastodon.Entity.NotificationType {
return "questionmark.square.dashed"
case .mention:
// TODO: make this nil when full status view is available
return "quote.bubble.fill"
switch visibility {
case .direct:
return "at.circle.fill"
default:
return "at"
}
case .status:
// TODO: make this nil when full status view is available
return "bell.fill"
Expand Down Expand Up @@ -217,7 +226,8 @@ func NotificationIconView(_ info: NotificationIconInfo) -> some View {
HStack {
Image(
systemName: info.notificationType.iconSystemName(
grouped: info.isGrouped) ?? "questionmark.square.dashed"
grouped: info.isGrouped, visibility: info.visibility)
?? "questionmark.square.dashed"
)
.foregroundStyle(info.notificationType.iconColor)
}
Expand Down Expand Up @@ -319,6 +329,7 @@ extension Mastodon.Entity.Relationship {
struct NotificationIconInfo {
let notificationType: Mastodon.Entity.NotificationType
let isGrouped: Bool
let visibility: Mastodon.Entity.Status.Visibility?
}

struct NotificationSourceAccounts {
Expand Down Expand Up @@ -386,7 +397,7 @@ struct FilteredNotificationsRowView: View {
NotificationIconView(systemName: "archivebox")
Spacer().frame(maxHeight: .infinity)
}

// TEXT COMPONENTS
VStack {
ForEach(viewModel.componentViews) { component in
Expand All @@ -398,7 +409,7 @@ struct FilteredNotificationsRowView: View {
}
}
}

// DISCLOSURE INDICATOR (OR SPINNER)
VStack {
Spacer()
Expand Down Expand Up @@ -428,10 +439,15 @@ struct NotificationRowView: View {
}

// VSTACK OF HEADER AND CONTENT COMPONENT VIEWS
VStack {
VStack(spacing: 2) {
ForEach(viewModel.headerComponents) {
componentView($0)
}

if !viewModel.contentComponents.isEmpty {
Spacer().frame(height: 4)
}

ForEach(viewModel.contentComponents) {
componentView($0)
}
Expand Down Expand Up @@ -480,15 +496,11 @@ struct NotificationRowView: View {
}
}

func displayableAvatarCount(totalAvatarCount: Int, totalActorCount: Int)
-> Int
{
// Make space for the "+ more" label
// Unfortunately, using GeometryReader to avoid using a default max count resulted in weird layout side-effects.
var maxAvatarCount = 8
if maxAvatarCount < totalActorCount {
maxAvatarCount = maxAvatarCount - 2
}
func displayableAvatarCount(
fittingWidth: CGFloat, totalAvatarCount: Int, totalActorCount: Int
) -> Int {
let maxAvatarCount = Int(
floor(fittingWidth / (smallAvatarSize + avatarSpacing)))
return maxAvatarCount
}

Expand All @@ -501,38 +513,39 @@ struct NotificationRowView: View {
accountInfo: NotificationSourceAccounts,
trailingElement: RelationshipElement
) -> some View {
let maxAvatarCount = displayableAvatarCount(
totalAvatarCount: accountInfo.avatarUrls.count,
totalActorCount: accountInfo.totalActorCount)
let needsMoreLabel = accountInfo.totalActorCount > maxAvatarCount
HStack(alignment: .center) {
ForEach(accountInfo.avatarUrls.prefix(maxAvatarCount), id: \.self) {
AsyncImage(
url: $0,
content: { image in
image.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(avatarShape)
.overlay {
avatarShape.stroke(.separator)
}
},
placeholder: {
avatarShape
.foregroundStyle(Color(UIColor.secondarySystemFill))
}
)
.frame(width: smallAvatarSize, height: smallAvatarSize)
}
if needsMoreLabel {
Text("+ more")
.fixedSize()
.lineLimit(1)
GeometryReader { geom in
let maxAvatarCount = displayableAvatarCount(
fittingWidth: geom.size.width,
totalAvatarCount: accountInfo.avatarUrls.count,
totalActorCount: accountInfo.totalActorCount)
HStack(alignment: .center) {
ForEach(
accountInfo.avatarUrls.prefix(maxAvatarCount), id: \.self
) {
AsyncImage(
url: $0,
content: { image in
image.resizable()
.aspectRatio(contentMode: .fit)
.clipShape(avatarShape)
.overlay {
avatarShape.stroke(.separator)
}
},
placeholder: {
avatarShape
.foregroundStyle(
Color(UIColor.secondarySystemFill))
}
)
.frame(width: smallAvatarSize, height: smallAvatarSize)
}
Spacer().frame(minWidth: 0, maxWidth: .infinity)
avatarRowTrailingElement(
trailingElement, grouped: accountInfo.totalActorCount > 1)
}
Spacer().frame(minWidth: 0, maxWidth: .infinity)
avatarRowTrailingElement(
trailingElement, grouped: accountInfo.totalActorCount > 1)
}
.frame(height: smallAvatarSize) // this keeps GeometryReader from causing inconsistent visual spacing in the VStack
}

@ViewBuilder
Expand Down Expand Up @@ -673,16 +686,17 @@ extension Mastodon.Entity.Status {
case audio(Int)
case generic(Int)
case poll

var count: Int {
switch self {
case .image(let count), .gifv(let count), .video(let count), .audio(let count), .generic(let count):
case .image(let count), .gifv(let count), .video(let count),
.audio(let count), .generic(let count):
return count
case .poll:
return 1
}
}

var iconName: String {
switch self {
case .image(1):
Expand All @@ -705,7 +719,7 @@ extension Mastodon.Entity.Status {
return "chart.bar.yaxis"
}
}

var labelText: String {
switch self {
case .image(let count):
Expand All @@ -722,8 +736,9 @@ extension Mastodon.Entity.Status {
return L10n.Plural.Count.poll(1)
}
}

private func withUpdatedCount(_ newCount: Int) -> AttachmentSummaryInfo {

private func withUpdatedCount(_ newCount: Int) -> AttachmentSummaryInfo
{
switch self {
case .image:
return .image(newCount)
Expand All @@ -739,23 +754,30 @@ extension Mastodon.Entity.Status {
return .poll
}
}

private func _adding(_ otherAttachmentInfo: AttachmentSummaryInfo) -> AttachmentSummaryInfo {

private func _adding(_ otherAttachmentInfo: AttachmentSummaryInfo)
-> AttachmentSummaryInfo
{
switch (self, otherAttachmentInfo) {
case (.poll, _), (_, .poll):
assertionFailure("did not expect poll to co-occur with another attachment type")
assertionFailure(
"did not expect poll to co-occur with another attachment type"
)
return .poll
case (.gifv, .gifv), (.image, .image), (.video, .video), (.audio, .audio):
case (.gifv, .gifv), (.image, .image), (.video, .video),
(.audio, .audio):
return withUpdatedCount(count + otherAttachmentInfo.count)
default:
return .generic(count + otherAttachmentInfo.count)
}
}

func adding(attachment: Mastodon.Entity.Attachment) -> AttachmentSummaryInfo {

func adding(attachment: Mastodon.Entity.Attachment)
-> AttachmentSummaryInfo
{
return _adding(AttachmentSummaryInfo(attachment))
}

init(_ attachment: Mastodon.Entity.Attachment) {
switch attachment.type {
case .image:
Expand All @@ -776,6 +798,7 @@ extension Mastodon.Entity.Status {
extension Mastodon.Entity.Status {
public struct ViewModel {
public let content: AttributedString?
public let visibility: Mastodon.Entity.Status.Visibility?
public let isPinned: Bool
public let accountDisplayName: String?
public let accountFullName: String?
Expand All @@ -787,27 +810,37 @@ extension Mastodon.Entity.Status {
public let navigateToStatus: () -> Void
}

public func viewModel(myDomain: String, navigateToStatus: @escaping () -> Void) -> ViewModel {
public func viewModel(
myDomain: String, navigateToStatus: @escaping () -> Void
) -> ViewModel {
let displayableContent: AttributedString
if let content {
displayableContent = attributedString(
fromHtml: content, emojis: account.emojis.asDictionary)
} else {
displayableContent = AttributedString()
}
let accountFullName = account.domain == myDomain ? account.acct : account.acctWithDomain
let attachmentInfo = mediaAttachments?.reduce(nil, { (partialResult: AttachmentSummaryInfo?, attachment: Mastodon.Entity.Attachment) in
if let partialResult = partialResult {
return partialResult.adding(attachment: attachment)
} else {
return AttachmentSummaryInfo(attachment)
}
})

let accountFullName =
account.domain == myDomain ? account.acct : account.acctWithDomain
let attachmentInfo = mediaAttachments?.reduce(
nil,
{
(
partialResult: AttachmentSummaryInfo?,
attachment: Mastodon.Entity.Attachment
) in
if let partialResult = partialResult {
return partialResult.adding(attachment: attachment)
} else {
return AttachmentSummaryInfo(attachment)
}
})

let pollInfo: AttachmentSummaryInfo? = poll != nil ? .poll : nil

return ViewModel(
content: displayableContent, isPinned: false,
content: displayableContent, visibility: visibility,
isPinned: false,
accountDisplayName: account.displayName,
accountFullName: accountFullName,
accountAvatarUrl: account.avatarImageURL(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ class NotificationRowViewModel: ObservableObject {
self.type = notificationInfo.type
self.iconInfo = NotificationIconInfo(
notificationType: notificationInfo.type,
isGrouped: notificationInfo.isGrouped)
isGrouped: notificationInfo.isGrouped,
visibility: notificationInfo.statusViewModel?.visibility)
self.navigateToScene = navigateToScene
self.presentError = presentError
self.defaultNavigation = notificationInfo.defaultNavigation
Expand Down Expand Up @@ -106,7 +107,7 @@ class NotificationRowViewModel: ObservableObject {
}
case .reblog, .favourite:
if let primaryAuthorAccount = notificationInfo.primaryAuthorAccount,
let statusViewModel = notificationInfo.statusViewModel
let statusViewModel = notificationInfo.statusViewModel
{
avatarRow = .avatarRow(
NotificationSourceAccounts(
Expand Down Expand Up @@ -483,7 +484,7 @@ extension NotificationRowViewModel {
presentError: presentError)
}
}

static func viewModelsFromUngroupedNotifications(
_ notifications: [Mastodon.Entity.Notification],
myAccountID: String,
Expand All @@ -492,7 +493,7 @@ extension NotificationRowViewModel {
SceneCoordinator.Scene, SceneCoordinator.Transition
) -> Void, presentError: @escaping (Error) -> Void
) -> [NotificationRowViewModel] {

return notifications.map { notification in
let info = GroupedNotificationInfo(
id: notification.id,
Expand All @@ -511,7 +512,8 @@ extension NotificationRowViewModel {
guard
let authBox =
await AuthenticationServiceProvider.shared
.currentActiveUser.value, let status = notification.status
.currentActiveUser.value,
let status = notification.status
else { return }
await navigateToScene(
.thread(
Expand Down Expand Up @@ -540,7 +542,7 @@ extension NotificationRowViewModel {
}
}
)

return NotificationRowViewModel(
info, navigateToScene: navigateToScene,
presentError: presentError)
Expand Down

0 comments on commit bfee18e

Please sign in to comment.