iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: redesign notifications with thumbnails, inline text, and notification preferences

- Redesign single notification rows: inline name + action + timestamp, avatar above text
- Redesign grouped notification rows: inline action text with timestamp, thumbnail overlay
- Add gallery/story thumbnail previews (CachedThumbnailView) vertically centered, right-aligned
- Add notification preference settings (push, in-app, from follows/all)
- Update OverlappingAvatarsUIView: remove inner ring, use registerForTraitChanges for light/dark
- Update follow list and grouped authors: name + @handle on same line, description below
- Add subtle border overlay to all avatars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+868 -134
+31
Grain/API/Endpoints/FeedEndpoints.swift
··· 21 21 let preferences: UserPreferences 22 22 } 23 23 24 + struct NotifPref: Codable, Sendable { 25 + var push: Bool 26 + var inApp: Bool 27 + var from: String // "all" or "follows" 28 + 29 + static let `default` = NotifPref(push: true, inApp: true, from: "all") 30 + } 31 + 32 + struct NotificationPrefs: Codable, Sendable { 33 + var favorites: NotifPref? 34 + var follows: NotifPref? 35 + var comments: NotifPref? 36 + var mentions: NotifPref? 37 + 38 + static let `default` = NotificationPrefs( 39 + favorites: .default, 40 + follows: .default, 41 + comments: .default, 42 + mentions: .default 43 + ) 44 + } 45 + 24 46 struct UserPreferences: Codable, Sendable { 25 47 var pinnedFeeds: [PinnedFeed]? 26 48 var includeExif: Bool? 27 49 var includeLocation: Bool? 50 + var notificationPrefs: NotificationPrefs? 28 51 } 29 52 30 53 struct PinnedFeed: Codable, Sendable, Identifiable, Hashable { ··· 124 147 let value: Bool 125 148 } 126 149 try await procedure("dev.hatk.putPreference", input: Input(key: "includeLocation", value: value), auth: auth) 150 + } 151 + 152 + func putNotificationPrefs(_ prefs: NotificationPrefs, auth: AuthContext? = nil) async throws { 153 + struct Input: Encodable { 154 + let key: String 155 + let value: NotificationPrefs 156 + } 157 + try await procedure("dev.hatk.putPreference", input: Input(key: "notificationPrefs", value: prefs), auth: auth) 127 158 } 128 159 129 160 func getActorFavorites(
+1 -1
Grain/API/Endpoints/NotificationEndpoints.swift
··· 17 17 } 18 18 19 19 func getNotifications( 20 - limit: Int = 20, 20 + limit: Int = 100, 21 21 cursor: String? = nil, 22 22 countOnly: Bool = false, 23 23 auth: AuthContext? = nil
+153
Grain/Models/Views/NotificationModels.swift
··· 33 33 case reply 34 34 case follow 35 35 case unknown 36 + 37 + var isGroupable: Bool { 38 + switch self { 39 + case .galleryFavorite, .storyFavorite, .follow: true 40 + default: false 41 + } 42 + } 43 + } 44 + 45 + struct GroupedNotification: Identifiable, Equatable, Hashable { 46 + static func == (lhs: GroupedNotification, rhs: GroupedNotification) -> Bool { 47 + lhs.notification.uri == rhs.notification.uri 48 + && lhs.cachedAuthors.count == rhs.cachedAuthors.count 49 + } 50 + 51 + func hash(into hasher: inout Hasher) { 52 + hasher.combine(notification.uri) 53 + } 54 + 55 + let notification: GrainNotification 56 + var additional: [GrainNotification] 57 + /// Pre-computed on creation so SwiftUI doesn't recompute during layout. 58 + private(set) var cachedAuthors: [GrainProfile] 59 + 60 + var id: String { 61 + notification.uri 62 + } 63 + 64 + var authorCount: Int { 65 + cachedAuthors.count 66 + } 67 + 68 + var isGrouped: Bool { 69 + !additional.isEmpty 70 + } 71 + 72 + var allAuthors: [GrainProfile] { 73 + cachedAuthors 74 + } 75 + 76 + init(notification: GrainNotification, additional: [GrainNotification]) { 77 + self.notification = notification 78 + self.additional = additional 79 + var seen = Set<String>() 80 + var authors: [GrainProfile] = [] 81 + for notif in [notification] + additional { 82 + if seen.insert(notif.author.did).inserted { 83 + authors.append(notif.author) 84 + } 85 + } 86 + cachedAuthors = authors 87 + } 88 + 89 + mutating func addAuthor(_ notif: GrainNotification) { 90 + additional.append(notif) 91 + if !cachedAuthors.contains(where: { $0.did == notif.author.did }) { 92 + cachedAuthors.append(notif.author) 93 + } 94 + } 95 + 96 + private nonisolated(unsafe) static let dateFormatter: ISO8601DateFormatter = { 97 + let f = ISO8601DateFormatter() 98 + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 99 + return f 100 + }() 101 + 102 + @MainActor 103 + private static func parseDate(_ str: String) -> TimeInterval { 104 + dateFormatter.date(from: str)?.timeIntervalSince1970 ?? 0 105 + } 106 + 107 + @MainActor 108 + static func group(_ notifications: [GrainNotification]) -> [GroupedNotification] { 109 + let twoDays: TimeInterval = 48 * 60 * 60 110 + var groups: [GroupedNotification] = [] 111 + 112 + for notif in notifications { 113 + guard notif.reasonType.isGroupable else { 114 + groups.append(GroupedNotification(notification: notif, additional: [])) 115 + continue 116 + } 117 + 118 + let ts = parseDate(notif.createdAt) 119 + var matched = false 120 + 121 + for i in groups.indices { 122 + let g = groups[i] 123 + let gts = parseDate(g.notification.createdAt) 124 + 125 + guard abs(gts - ts) < twoDays, 126 + notif.reasonType == g.notification.reasonType, 127 + subjectKey(notif) == subjectKey(g.notification), 128 + notif.author.did != g.notification.author.did 129 + else { continue } 130 + 131 + let alreadyHas = g.additional.contains { $0.author.did == notif.author.did } 132 + if !alreadyHas { 133 + groups[i].addAuthor(notif) 134 + } 135 + matched = true 136 + break 137 + } 138 + 139 + if !matched { 140 + groups.append(GroupedNotification(notification: notif, additional: [])) 141 + } 142 + } 143 + 144 + return groups 145 + } 146 + 147 + /// Merge a new page into existing groups without regrouping the entire list. 148 + @MainActor 149 + static func mergeNewPage(_ newNotifs: [GrainNotification], into groups: inout [GroupedNotification]) { 150 + let twoDays: TimeInterval = 48 * 60 * 60 151 + 152 + for notif in newNotifs { 153 + guard notif.reasonType.isGroupable else { 154 + groups.append(GroupedNotification(notification: notif, additional: [])) 155 + continue 156 + } 157 + 158 + let ts = parseDate(notif.createdAt) 159 + var matched = false 160 + 161 + for i in groups.indices { 162 + let g = groups[i] 163 + let gts = parseDate(g.notification.createdAt) 164 + 165 + guard abs(gts - ts) < twoDays, 166 + notif.reasonType == g.notification.reasonType, 167 + subjectKey(notif) == subjectKey(g.notification), 168 + notif.author.did != g.notification.author.did 169 + else { continue } 170 + 171 + let alreadyHas = g.additional.contains { $0.author.did == notif.author.did } 172 + if !alreadyHas { 173 + groups[i].addAuthor(notif) 174 + } 175 + matched = true 176 + break 177 + } 178 + 179 + if !matched { 180 + groups.append(GroupedNotification(notification: notif, additional: [])) 181 + } 182 + } 183 + } 184 + 185 + private static func subjectKey(_ notif: GrainNotification) -> String { 186 + if notif.reasonType == .follow { return "__follow__" } 187 + return notif.galleryUri ?? notif.storyUri ?? notif.uri 188 + } 36 189 }
+27 -5
Grain/ViewModels/NotificationsViewModel.swift
··· 1 1 import Foundation 2 + import Nuke 3 + import SwiftUI 2 4 3 5 @Observable 4 6 @MainActor 5 7 final class NotificationsViewModel { 6 8 var notifications: [GrainNotification] = [] 9 + var grouped: [GroupedNotification] = [] 7 10 var unseenCount: Int = 0 8 11 var isLoading = false 9 12 var error: Error? ··· 11 14 private var cursor: String? 12 15 private var hasMore = true 13 16 private var client: XRPCClient 17 + private var prefetcher = ImagePrefetcher() 14 18 15 19 init(client: XRPCClient) { 16 20 self.client = client ··· 29 33 30 34 do { 31 35 let response = try await client.getNotifications(auth: auth) 32 - notifications = response.notifications 33 - unseenCount = response.unseenCount ?? 0 34 - cursor = response.cursor 35 - hasMore = response.cursor != nil 36 + withAnimation(nil) { 37 + notifications = response.notifications 38 + grouped = GroupedNotification.group(notifications) 39 + unseenCount = response.unseenCount ?? 0 40 + cursor = response.cursor 41 + hasMore = response.cursor != nil 42 + } 43 + prefetchImages(response.notifications) 36 44 } catch { 37 45 self.error = error 38 46 } ··· 45 53 46 54 do { 47 55 let response = try await client.getNotifications(cursor: cursor, auth: auth) 48 - notifications.append(contentsOf: response.notifications) 56 + var updatedGroups = grouped 57 + GroupedNotification.mergeNewPage(response.notifications, into: &updatedGroups) 58 + withAnimation(nil) { 59 + notifications.append(contentsOf: response.notifications) 60 + grouped = updatedGroups 61 + } 49 62 self.cursor = response.cursor 50 63 hasMore = response.cursor != nil 64 + prefetchImages(response.notifications) 51 65 } catch { 52 66 self.error = error 53 67 } ··· 63 77 } catch { 64 78 unseenCount = previousCount 65 79 } 80 + } 81 + 82 + private func prefetchImages(_ notifs: [GrainNotification]) { 83 + var urlStrings = notifs.compactMap(\.author.avatar) 84 + urlStrings += notifs.compactMap(\.galleryThumb) 85 + urlStrings += notifs.compactMap(\.storyThumb) 86 + let urls = urlStrings.compactMap { URL(string: $0) } 87 + prefetcher.startPrefetching(with: urls) 66 88 } 67 89 68 90 func fetchUnseenCount(auth: AuthContext? = nil) async {
+40 -25
Grain/Views/Components/AvatarView.swift
··· 1 + import Nuke 1 2 import NukeUI 2 3 import SwiftUI 3 4 ··· 8 9 /// an animated parent (e.g. story parallax pane) so it snaps atomically. 9 10 var animated: Bool = true 10 11 11 - /// Retains the last successfully loaded image so URL changes don't flash gray. 12 - @State private var lastUIImage: UIImage? 12 + /// Only set for async cache misses — cache hits are read synchronously in body. 13 + @State private var asyncImage: UIImage? 14 + 15 + private var imageURL: URL? { 16 + guard let url else { return nil } 17 + return URL(string: url) 18 + } 19 + 20 + private static let placeholder = UIImage() 13 21 14 22 var body: some View { 15 - if let url, let imageURL = URL(string: url) { 16 - LazyImage(url: imageURL) { state in 17 - if let uiImage = state.imageContainer?.image { 18 - Image(uiImage: uiImage) 19 - .resizable() 20 - .transition(animated ? .opacity : .identity) 21 - .onAppear { lastUIImage = uiImage } 22 - } else if let prev = lastUIImage { 23 - // Show previous image while new URL loads — no gray flash 24 - Image(uiImage: prev) 25 - .resizable() 26 - .transition(.identity) 27 - } else { 28 - fallback 29 - .transition(animated ? .opacity : .identity) 30 - } 23 + Image(uiImage: resolvedImage ?? Self.placeholder) 24 + .resizable() 25 + .frame(width: size, height: size) 26 + .background { 27 + fallback 31 28 } 32 - .frame(width: size, height: size) 33 29 .clipShape(Circle()) 34 - } else { 35 - fallback 36 - .frame(width: size, height: size) 37 - .clipShape(Circle()) 30 + .overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) 31 + .onAppear { loadIfNeeded() } 32 + } 33 + 34 + /// Synchronous image resolution — checks memory cache first, then falls back to async-loaded image. 35 + private var resolvedImage: UIImage? { 36 + if let imageURL { 37 + if let cached = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: imageURL))?.image { 38 + return cached 39 + } 40 + } 41 + return asyncImage 42 + } 43 + 44 + private func loadIfNeeded() { 45 + guard let imageURL else { return } 46 + let request = ImageRequest(url: imageURL) 47 + // If in memory cache, no state change needed — resolvedImage picks it up 48 + if ImagePipeline.shared.cache.cachedImage(for: request) != nil { return } 49 + // Only go async for true cache misses 50 + guard asyncImage == nil else { return } 51 + Task { 52 + if let image = try? await ImagePipeline.shared.image(for: request) { 53 + asyncImage = image 54 + } 38 55 } 39 56 } 40 57 ··· 50 67 51 68 #Preview { 52 69 VStack(spacing: 24) { 53 - // Fallback state — no URL, all three canonical sizes side by side 54 70 VStack(spacing: 8) { 55 71 Text("Fallback (nil URL)") 56 72 .font(.caption) ··· 64 80 65 81 Divider() 66 82 67 - // Bad URL — exercises the loading-failed → fallback path 68 83 VStack(spacing: 8) { 69 84 Text("Bad URL (load failure fallback)") 70 85 .font(.caption)
+504 -93
Grain/Views/Notifications/NotificationsView.swift
··· 1 - import NukeUI 1 + import Nuke 2 2 import SwiftUI 3 3 4 4 struct NotificationsView: View { 5 5 @Environment(AuthManager.self) private var auth 6 - @Environment(StoryStatusCache.self) private var storyStatusCache 7 6 var viewModel: NotificationsViewModel 8 7 @State private var selectedGalleryUri: String? 9 8 @State private var selectedProfileDid: String? 10 9 @State private var cardStoryAuthor: GrainStoryAuthor? 11 10 @State private var selectedStory: GrainStory? 11 + @State private var selectedGroup: GroupedNotification? 12 12 let client: XRPCClient 13 13 14 14 init(client: XRPCClient, viewModel: NotificationsViewModel) { ··· 18 18 19 19 var body: some View { 20 20 NavigationStack { 21 - List { 22 - ForEach(viewModel.notifications) { notification in 23 - NotificationRow(notification: notification, userDID: auth.userDID, onProfileTap: { did in 24 - selectedProfileDid = did 25 - }, onStoryTap: { author in 26 - cardStoryAuthor = author 27 - }) 28 - .contentShape(Rectangle()) 29 - .onTapGesture { 30 - if notification.reasonType == .follow { 31 - selectedProfileDid = notification.author.did 32 - } else if notification.reasonType == .storyFavorite || notification.reasonType == .storyComment { 33 - if let storyUri = notification.storyUri { 34 - Task { 35 - if let story = try? await client.getStory(uri: storyUri, auth: auth.authContext()).story { 36 - selectedStory = story 37 - } 38 - } 39 - } else { 40 - selectedProfileDid = notification.author.did 41 - } 42 - } else if let galleryUri = notification.galleryUri { 43 - selectedGalleryUri = galleryUri 44 - } 45 - } 46 - .swipeActions(edge: .leading) { 47 - Button { 48 - selectedProfileDid = notification.author.did 49 - } label: { 50 - Label("Profile", systemImage: "person") 51 - } 52 - } 53 - .onAppear { 54 - if notification.id == viewModel.notifications.last?.id { 55 - Task { await viewModel.loadMore(auth: auth.authContext()) } 56 - } 57 - } 58 - } 59 - 60 - if viewModel.isLoading { 61 - HStack { 62 - Spacer() 63 - ProgressView() 64 - Spacer() 65 - } 66 - } 67 - } 68 - .listStyle(.plain) 69 - .refreshable { 70 - await viewModel.loadInitial(auth: auth.authContext()) 71 - } 21 + NotificationListContent( 22 + viewModel: viewModel, 23 + client: client, 24 + authContext: { await auth.authContext() }, 25 + onProfileTap: { selectedProfileDid = $0 }, 26 + onGalleryTap: { selectedGalleryUri = $0 }, 27 + onStoryAuthorTap: { cardStoryAuthor = $0 }, 28 + onStoryTap: { selectedStory = $0 }, 29 + onGroupTap: { selectedGroup = $0 } 30 + ) 72 31 .navigationTitle("Notifications") 73 32 .navigationDestination(item: $selectedGalleryUri) { uri in 74 33 GalleryDetailView(client: client, galleryUri: uri) 75 34 } 76 35 .navigationDestination(item: $selectedProfileDid) { did in 77 36 ProfileView(client: client, did: did) 37 + } 38 + .navigationDestination(item: $selectedGroup) { group in 39 + GroupedAuthorsView( 40 + group: group, 41 + client: client 42 + ) 78 43 } 79 44 .fullScreenCover(item: $cardStoryAuthor) { author in 80 45 StoryViewer( ··· 105 70 ) 106 71 .environment(auth) 107 72 } 108 - .task(id: viewModel.unseenCount) { 109 - if viewModel.notifications.isEmpty || viewModel.unseenCount > 0 { 73 + .task { 74 + if viewModel.notifications.isEmpty { 110 75 await viewModel.loadInitial(auth: auth.authContext()) 111 76 } 112 - await viewModel.markAsSeen(auth: auth.authContext()) 77 + if viewModel.unseenCount > 0 { 78 + await viewModel.markAsSeen(auth: auth.authContext()) 79 + } 113 80 } 114 81 } 115 82 } 116 83 } 117 84 118 - struct NotificationRow: View { 119 - @Environment(StoryStatusCache.self) private var storyStatusCache 120 - @Environment(ViewedStoryStorage.self) private var viewedStories 121 - let notification: GrainNotification 122 - let userDID: String? 123 - var onProfileTap: ((String) -> Void)? 124 - var onStoryTap: ((GrainStoryAuthor) -> Void)? 85 + // MARK: - List Content (no @Environment — auth passed as closure) 86 + 87 + private struct NotificationListContent: View { 88 + let viewModel: NotificationsViewModel 89 + let client: XRPCClient 90 + let authContext: () async -> AuthContext? 91 + let onProfileTap: (String) -> Void 92 + let onGalleryTap: (String) -> Void 93 + let onStoryAuthorTap: (GrainStoryAuthor) -> Void 94 + let onStoryTap: (GrainStory) -> Void 95 + let onGroupTap: (GroupedNotification) -> Void 125 96 126 97 var body: some View { 127 - HStack(alignment: .top, spacing: 12) { 128 - StoryRingView(hasStory: storyStatusCache.hasStory(for: notification.author.did), viewed: notification.author.did != userDID && viewedStories.hasViewedAll(did: notification.author.did, storyStatusCache: storyStatusCache), size: 36) { 129 - AvatarView(url: notification.author.avatar, size: 36) 98 + List { 99 + ForEach(viewModel.grouped) { group in 100 + NotificationRowContainer( 101 + group: group, 102 + client: client, 103 + authContext: authContext, 104 + onProfileTap: onProfileTap, 105 + onGalleryTap: onGalleryTap, 106 + onStoryAuthorTap: onStoryAuthorTap, 107 + onStoryTap: onStoryTap, 108 + onGroupTap: onGroupTap 109 + ) 110 + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) 111 + .onAppear { 112 + if group.id == viewModel.grouped.last?.id { 113 + Task { await viewModel.loadMore(auth: authContext()) } 114 + } 115 + } 130 116 } 117 + .listRowSeparator(.visible) 118 + .listRowSeparatorTint(Color(.separator)) 119 + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } 120 + } 121 + .listStyle(.plain) 122 + .refreshable { 123 + await viewModel.loadInitial(auth: authContext()) 124 + } 125 + } 126 + } 127 + 128 + // MARK: - Row Container (no @Environment — auth passed as closure) 129 + 130 + private struct NotificationRowContainer: View { 131 + let group: GroupedNotification 132 + let client: XRPCClient 133 + let authContext: () async -> AuthContext? 134 + let onProfileTap: (String) -> Void 135 + let onGalleryTap: (String) -> Void 136 + let onStoryAuthorTap: (GrainStoryAuthor) -> Void 137 + let onStoryTap: (GrainStory) -> Void 138 + let onGroupTap: (GroupedNotification) -> Void 139 + 140 + var body: some View { 141 + if group.isGrouped { 142 + GroupedNotificationRow( 143 + group: group, 144 + onProfileTap: onProfileTap, 145 + onSubjectTap: { handleTap(group.notification) }, 146 + onGroupTap: { onGroupTap(group) } 147 + ) 148 + } else { 149 + SingleNotificationRow( 150 + notification: group.notification, 151 + onProfileTap: onProfileTap, 152 + onSubjectTap: { handleTap(group.notification) } 153 + ) 154 + .contentShape(Rectangle()) 131 155 .onTapGesture { 132 - if let author = storyStatusCache.author(for: notification.author.did) { 133 - onStoryTap?(author) 134 - } else { 135 - onProfileTap?(notification.author.did) 156 + handleTap(group.notification) 157 + } 158 + .swipeActions(edge: .leading) { 159 + Button { 160 + onProfileTap(group.notification.author.did) 161 + } label: { 162 + Label("Profile", systemImage: "person") 136 163 } 137 164 } 138 - .onLongPressGesture { 139 - onProfileTap?(notification.author.did) 165 + } 166 + } 167 + 168 + private func handleTap(_ notification: GrainNotification) { 169 + if notification.reasonType == .follow { 170 + onProfileTap(notification.author.did) 171 + } else if notification.reasonType == .storyFavorite || notification.reasonType == .storyComment { 172 + if let storyUri = notification.storyUri { 173 + Task { 174 + if let story = try? await client.getStory(uri: storyUri, auth: authContext()).story { 175 + onStoryTap(story) 176 + } 177 + } 178 + } else { 179 + onProfileTap(notification.author.did) 140 180 } 181 + } else if let galleryUri = notification.galleryUri { 182 + onGalleryTap(galleryUri) 183 + } 184 + } 185 + } 186 + 187 + // MARK: - Reason Icon 188 + 189 + private struct ReasonIcon: View { 190 + let reason: NotificationReason 191 + 192 + private var iconName: String { 193 + switch reason { 194 + case .galleryFavorite, .storyFavorite: "heart.fill" 195 + case .follow: "person.fill.badge.plus" 196 + case .galleryComment, .storyComment: "bubble.left.fill" 197 + case .reply: "arrowshape.turn.up.left.fill" 198 + case .galleryCommentMention, .galleryMention: "at" 199 + case .unknown: "bell.fill" 200 + } 201 + } 202 + 203 + var body: some View { 204 + Image(systemName: iconName) 205 + .foregroundStyle(Color("AccentColor")) 206 + .font(.system(size: 14)) 207 + .frame(width: 20) 208 + } 209 + } 210 + 211 + // MARK: - Grouped Notification Row 212 + 213 + private struct GroupedNotificationRow: View { 214 + let group: GroupedNotification 215 + var onProfileTap: ((String) -> Void)? 216 + var onSubjectTap: (() -> Void)? 217 + var onGroupTap: (() -> Void)? 218 + 219 + private var thumb: String? { 220 + group.notification.galleryThumb ?? group.notification.storyThumb 221 + } 141 222 142 - VStack(alignment: .leading, spacing: 4) { 143 - Text("\(Text(notification.author.displayName ?? notification.author.handle).font(.subheadline.bold())) \(Text(reasonText).font(.subheadline).foregroundStyle(.secondary))") 223 + var body: some View { 224 + HStack(alignment: .top, spacing: 10) { 225 + ReasonIcon(reason: group.notification.reasonType) 226 + .padding(.top, 4) 144 227 145 - if let galleryTitle = notification.galleryTitle { 146 - Text(galleryTitle) 147 - .font(.caption) 148 - .foregroundStyle(.secondary) 149 - .lineLimit(1) 228 + VStack(alignment: .leading, spacing: 6) { 229 + HStack(spacing: 0) { 230 + OverlappingAvatarsView( 231 + authors: Array(group.allAuthors.prefix(5)), 232 + size: 38, 233 + overlap: 8, 234 + onProfileTap: onProfileTap 235 + ) 236 + if group.authorCount > 5 { 237 + Button { 238 + onGroupTap?() 239 + } label: { 240 + Text("+\(group.authorCount - 5)") 241 + .font(.caption2.bold()) 242 + .foregroundStyle(.secondary) 243 + .padding(.horizontal, 6) 244 + .padding(.vertical, 2) 245 + .background(Color(.tertiarySystemFill), in: Capsule()) 246 + } 247 + .buttonStyle(.plain) 248 + .padding(.leading, 6) 249 + } 250 + Spacer(minLength: 0) 150 251 } 151 252 152 - if let commentText = notification.commentText { 153 - Text(commentText) 154 - .font(.caption) 155 - .lineLimit(2) 253 + let name = group.notification.author.displayName ?? group.notification.author.handle 254 + let othersCount = group.authorCount - 1 255 + let others = othersCount == 1 ? "1 other" : "\(othersCount) others" 256 + Button { 257 + onSubjectTap?() 258 + } label: { 259 + VStack(alignment: .leading, spacing: 4) { 260 + Text("\(Text(name).bold()) and \(others) \(reasonText) \(Text(DateFormatting.relativeTime(group.notification.createdAt)).foregroundStyle(.tertiary))") 261 + .font(.subheadline) 262 + .foregroundStyle(.primary) 263 + if group.notification.reasonType == .galleryFavorite, 264 + let title = group.notification.galleryTitle 265 + { 266 + Text(title) 267 + .font(.subheadline) 268 + .foregroundStyle(.secondary) 269 + .lineLimit(1) 270 + } 271 + } 272 + .frame(maxWidth: .infinity, alignment: .leading) 273 + .padding(.trailing, thumb != nil ? 54 : 0) 156 274 } 275 + .buttonStyle(.plain) 157 276 } 277 + } 278 + .frame(maxWidth: .infinity, alignment: .leading) 279 + .overlay(alignment: .trailing) { 280 + if let thumb { 281 + Button { onSubjectTap?() } label: { 282 + CachedThumbnailView(url: thumb, height: 44) 283 + } 284 + .buttonStyle(.plain) 285 + } 286 + } 287 + } 158 288 159 - Spacer() 289 + private var reasonText: String { 290 + switch group.notification.reasonType { 291 + case .galleryFavorite: "favorited your gallery" 292 + case .storyFavorite: "favorited your story" 293 + case .follow: "followed you" 294 + default: "" 295 + } 296 + } 297 + } 298 + 299 + // MARK: - Single Notification Row 300 + 301 + private struct SingleNotificationRow: View { 302 + let notification: GrainNotification 303 + var onProfileTap: ((String) -> Void)? 304 + var onSubjectTap: (() -> Void)? 305 + 306 + private var thumb: String? { 307 + notification.galleryThumb ?? notification.storyThumb 308 + } 160 309 161 - if let thumb = notification.galleryThumb ?? notification.storyThumb, let url = URL(string: thumb) { 162 - LazyImage(url: url) { state in 163 - if let image = state.image { 164 - image.resizable() 165 - } else { 166 - Rectangle().fill(.quaternary) 310 + var body: some View { 311 + HStack(alignment: .top, spacing: 10) { 312 + ReasonIcon(reason: notification.reasonType) 313 + .padding(.top, 4) 314 + 315 + VStack(alignment: .leading, spacing: 6) { 316 + AvatarView(url: notification.author.avatar, size: 34, animated: false) 317 + .onTapGesture { 318 + onProfileTap?(notification.author.did) 319 + } 320 + 321 + VStack(alignment: .leading, spacing: 2) { 322 + Text("\(Text(notification.author.displayName ?? notification.author.handle).bold()) \(reasonText) \(Text(DateFormatting.relativeTime(notification.createdAt)).foregroundStyle(.tertiary))") 323 + .font(.subheadline) 324 + .foregroundStyle(.primary) 325 + if notification.reasonType == .galleryFavorite, 326 + let title = notification.galleryTitle 327 + { 328 + Text(title) 329 + .font(.subheadline) 330 + .foregroundStyle(.secondary) 331 + .lineLimit(1) 332 + } 333 + if let commentText = notification.commentText { 334 + Text(commentText) 335 + .font(.subheadline) 336 + .foregroundStyle(.secondary) 337 + .lineLimit(3) 167 338 } 168 339 } 169 - .frame(width: 44, height: 44) 170 - .clipShape(RoundedRectangle(cornerRadius: 6)) 340 + .padding(.trailing, thumb != nil ? 54 : 0) 341 + } 342 + } 343 + .frame(maxWidth: .infinity, alignment: .leading) 344 + .overlay(alignment: .trailing) { 345 + if let thumb { 346 + Button { onSubjectTap?() } label: { 347 + CachedThumbnailView(url: thumb, height: 44) 348 + } 349 + .buttonStyle(.plain) 171 350 } 172 351 } 173 352 } ··· 177 356 case .galleryFavorite: "favorited your gallery" 178 357 case .galleryComment: "commented on your gallery" 179 358 case .galleryCommentMention: "mentioned you in a comment" 180 - case .galleryMention: "mentioned you" 359 + case .galleryMention: "mentioned you in a gallery" 181 360 case .storyFavorite: "favorited your story" 182 361 case .storyComment: "commented on your story" 183 362 case .reply: "replied to your comment" ··· 187 366 } 188 367 } 189 368 369 + // MARK: - Overlapping Avatars (UIKit-backed, zero SwiftUI layout participation) 370 + 371 + private struct OverlappingAvatarsView: UIViewRepresentable { 372 + let authors: [GrainProfile] 373 + let size: CGFloat 374 + let overlap: CGFloat 375 + var onProfileTap: ((String) -> Void)? 376 + 377 + private var totalWidth: CGFloat { 378 + guard !authors.isEmpty else { return 0 } 379 + return size + CGFloat(authors.count - 1) * (size - overlap) 380 + } 381 + 382 + func makeUIView(context _: Context) -> OverlappingAvatarsUIView { 383 + let view = OverlappingAvatarsUIView() 384 + view.onProfileTap = onProfileTap 385 + view.configure(authors: authors, size: size, overlap: overlap) 386 + return view 387 + } 388 + 389 + func updateUIView(_ uiView: OverlappingAvatarsUIView, context _: Context) { 390 + uiView.onProfileTap = onProfileTap 391 + uiView.configure(authors: authors, size: size, overlap: overlap) 392 + } 393 + 394 + func sizeThatFits(_: ProposedViewSize, uiView _: OverlappingAvatarsUIView, context _: Context) -> CGSize? { 395 + CGSize(width: totalWidth, height: size) 396 + } 397 + } 398 + 399 + final class OverlappingAvatarsUIView: UIView { 400 + var onProfileTap: ((String) -> Void)? 401 + private var avatarViews: [UIImageView] = [] 402 + private var authorDids: [String] = [] 403 + private var currentKey = "" 404 + 405 + override init(frame: CGRect) { 406 + super.init(frame: frame) 407 + isUserInteractionEnabled = true 408 + registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (view: OverlappingAvatarsUIView, _) in 409 + for iv in view.avatarViews { 410 + iv.layer.borderColor = UIColor.systemBackground.cgColor 411 + } 412 + } 413 + } 414 + 415 + @available(*, unavailable) 416 + required init?(coder _: NSCoder) { 417 + fatalError() 418 + } 419 + 420 + func configure(authors: [GrainProfile], size: CGFloat, overlap: CGFloat) { 421 + let key = authors.map(\.did).joined(separator: ",") 422 + guard key != currentKey else { return } 423 + currentKey = key 424 + authorDids = authors.map(\.did) 425 + 426 + // Remove old views 427 + avatarViews.forEach { $0.removeFromSuperview() } 428 + avatarViews.removeAll() 429 + 430 + let step = size - overlap 431 + 432 + for (i, author) in authors.enumerated() { 433 + let iv = UIImageView() 434 + iv.contentMode = .scaleAspectFill 435 + iv.clipsToBounds = true 436 + iv.layer.cornerRadius = size / 2 437 + iv.layer.borderWidth = 2 438 + iv.layer.borderColor = UIColor.systemBackground.cgColor 439 + iv.backgroundColor = UIColor.gray.withAlphaComponent(0.3) 440 + iv.frame = CGRect(x: CGFloat(i) * step, y: 0, width: size, height: size) 441 + 442 + // Load from Nuke memory cache synchronously, or fetch async 443 + if let url = author.avatar, let imageURL = URL(string: url) { 444 + let request = ImageRequest(url: imageURL) 445 + if let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image { 446 + iv.image = cached 447 + } else { 448 + Task { @MainActor in 449 + if let image = try? await ImagePipeline.shared.image(for: request) { 450 + iv.image = image 451 + } 452 + } 453 + } 454 + } 455 + 456 + addSubview(iv) 457 + avatarViews.append(iv) 458 + } 459 + 460 + let totalWidth = size + CGFloat(authors.count - 1) * step 461 + frame.size = CGSize(width: totalWidth, height: size) 462 + invalidateIntrinsicContentSize() 463 + } 464 + 465 + override var intrinsicContentSize: CGSize { 466 + frame.size 467 + } 468 + 469 + override func hitTest(_ point: CGPoint, with _: UIEvent?) -> UIView? { 470 + // Claim hit for any touch inside an avatar circle 471 + for iv in avatarViews.reversed() { 472 + if iv.frame.contains(point) { return self } 473 + } 474 + return nil 475 + } 476 + 477 + override func touchesEnded(_ touches: Set<UITouch>, with _: UIEvent?) { 478 + guard let touch = touches.first else { return } 479 + let location = touch.location(in: self) 480 + // Check avatars in reverse order (topmost first) 481 + for (i, iv) in avatarViews.enumerated().reversed() { 482 + if iv.frame.contains(location) { 483 + if i < authorDids.count { 484 + onProfileTap?(authorDids[i]) 485 + } 486 + return 487 + } 488 + } 489 + } 490 + } 491 + 492 + // MARK: - Cached Thumbnail (sync cache read, no LazyImage) 493 + 494 + private struct CachedThumbnailView: View { 495 + let url: String 496 + let height: CGFloat 497 + 498 + @State private var asyncImage: UIImage? 499 + 500 + private var imageURL: URL? { 501 + URL(string: url) 502 + } 503 + 504 + private var resolvedImage: UIImage? { 505 + if let imageURL, 506 + let cached = ImagePipeline.shared.cache.cachedImage(for: ImageRequest(url: imageURL))?.image 507 + { 508 + return cached 509 + } 510 + return asyncImage 511 + } 512 + 513 + var body: some View { 514 + Group { 515 + if let image = resolvedImage { 516 + Image(uiImage: image) 517 + .resizable() 518 + .aspectRatio(contentMode: .fit) 519 + } else { 520 + Rectangle().fill(.quaternary) 521 + .aspectRatio(1, contentMode: .fit) 522 + } 523 + } 524 + .frame(height: height) 525 + .clipShape(.rect(cornerRadius: 6)) 526 + .onAppear { loadIfNeeded() } 527 + } 528 + 529 + private func loadIfNeeded() { 530 + guard let imageURL else { return } 531 + let request = ImageRequest(url: imageURL) 532 + if ImagePipeline.shared.cache.cachedImage(for: request) != nil { return } 533 + guard asyncImage == nil else { return } 534 + Task { 535 + if let image = try? await ImagePipeline.shared.image(for: request) { 536 + asyncImage = image 537 + } 538 + } 539 + } 540 + } 541 + 542 + // MARK: - Grouped Authors Detail View 543 + 544 + private struct GroupedAuthorsView: View { 545 + let group: GroupedNotification 546 + let client: XRPCClient 547 + 548 + private var title: String { 549 + let count = group.authorCount 550 + switch group.notification.reasonType { 551 + case .galleryFavorite: return "\(count) Favorites" 552 + case .storyFavorite: return "\(count) Favorites" 553 + case .follow: return "\(count) Followers" 554 + default: return "\(count) People" 555 + } 556 + } 557 + 558 + var body: some View { 559 + List { 560 + ForEach(group.allAuthors, id: \.did) { author in 561 + NavigationLink { 562 + ProfileView(client: client, did: author.did) 563 + } label: { 564 + HStack(alignment: .center, spacing: 14) { 565 + AvatarView(url: author.avatar, size: 50, animated: false) 566 + 567 + VStack(alignment: .leading, spacing: 2) { 568 + HStack(spacing: 4) { 569 + if let displayName = author.displayName, !displayName.isEmpty { 570 + Text(displayName) 571 + .font(.body.weight(.semibold)) 572 + .lineLimit(1) 573 + } 574 + Text("@\(author.handle)") 575 + .font(.subheadline) 576 + .foregroundStyle(.secondary) 577 + .lineLimit(1) 578 + } 579 + if let desc = author.description, !desc.isEmpty { 580 + Text(desc) 581 + .font(.subheadline) 582 + .foregroundStyle(.secondary) 583 + .lineLimit(2) 584 + } 585 + } 586 + Spacer() 587 + } 588 + .padding(.vertical, 4) 589 + } 590 + .buttonStyle(.plain) 591 + .listRowSeparator(.visible) 592 + } 593 + } 594 + .listStyle(.plain) 595 + .navigationTitle(title) 596 + .navigationBarTitleDisplayMode(.inline) 597 + } 598 + } 599 + 190 600 #Preview { 191 601 let client = XRPCClient.preview 192 602 let vm = NotificationsViewModel(client: client) 193 603 vm.notifications = PreviewData.notifications 604 + vm.grouped = GroupedNotification.group(vm.notifications) 194 605 vm.unseenCount = 3 195 606 return NotificationsView(client: client, viewModel: vm) 196 607 .previewEnvironments()
+12 -10
Grain/Views/Profile/FollowListView.swift
··· 124 124 selectedProfileDid = item.did 125 125 } 126 126 VStack(alignment: .leading, spacing: 2) { 127 - if let displayName = item.displayName, !displayName.isEmpty { 128 - Text(displayName) 129 - .font(.body.weight(.semibold)) 130 - .lineLimit(1) 131 - } 132 - if let handle = item.handle { 133 - Text(handle) 134 - .font(.subheadline) 135 - .foregroundStyle(.secondary) 136 - .lineLimit(1) 127 + HStack(spacing: 4) { 128 + if let displayName = item.displayName, !displayName.isEmpty { 129 + Text(displayName) 130 + .font(.body.weight(.semibold)) 131 + .lineLimit(1) 132 + } 133 + if let handle = item.handle { 134 + Text("@\(handle)") 135 + .font(.subheadline) 136 + .foregroundStyle(.secondary) 137 + .lineLimit(1) 138 + } 137 139 } 138 140 if let desc = item.description, !desc.isEmpty { 139 141 Text(desc)
+92
Grain/Views/Settings/NotificationSettingsView.swift
··· 1 + import SwiftUI 2 + 3 + struct NotificationSettingsView: View { 4 + @Environment(AuthManager.self) private var auth 5 + let client: XRPCClient 6 + @State private var prefs = NotificationPrefs.default 7 + @State private var hasLoaded = false 8 + 9 + private let categories: [(key: String, label: String, desc: String, icon: String)] = [ 10 + ("favorites", "Favorites", "When someone favorites your gallery or story", "heart"), 11 + ("follows", "New followers", "When someone follows you", "person.badge.plus"), 12 + ("comments", "Comments", "When someone comments on your gallery or story", "bubble.left"), 13 + ("mentions", "Mentions", "When someone mentions you", "at"), 14 + ] 15 + 16 + var body: some View { 17 + List { 18 + ForEach(categories, id: \.key) { cat in 19 + Section { 20 + let pref = binding(for: cat.key) 21 + 22 + Toggle("Push notifications", isOn: Binding( 23 + get: { pref.wrappedValue.push }, 24 + set: { pref.wrappedValue.push = $0; save() } 25 + )) 26 + 27 + Toggle("In-app notifications", isOn: Binding( 28 + get: { pref.wrappedValue.inApp }, 29 + set: { pref.wrappedValue.inApp = $0; save() } 30 + )) 31 + 32 + Picker("From", selection: Binding( 33 + get: { pref.wrappedValue.from }, 34 + set: { pref.wrappedValue.from = $0; save() } 35 + )) { 36 + Text("Everyone").tag("all") 37 + Text("People I follow").tag("follows") 38 + } 39 + } header: { 40 + Label(cat.label, systemImage: cat.icon) 41 + } footer: { 42 + Text(cat.desc) 43 + } 44 + } 45 + } 46 + .navigationTitle("Notifications") 47 + .task { 48 + guard !hasLoaded else { return } 49 + if let authContext = await auth.authContext(), 50 + let response = try? await client.getPreferences(auth: authContext).preferences.notificationPrefs 51 + { 52 + prefs = response 53 + } 54 + hasLoaded = true 55 + } 56 + } 57 + 58 + private func binding(for key: String) -> Binding<NotifPref> { 59 + switch key { 60 + case "favorites": $prefs.favorites.defaulted() 61 + case "follows": $prefs.follows.defaulted() 62 + case "comments": $prefs.comments.defaulted() 63 + case "mentions": $prefs.mentions.defaulted() 64 + default: .constant(.default) 65 + } 66 + } 67 + 68 + private func save() { 69 + guard hasLoaded else { return } 70 + let current = prefs 71 + Task { 72 + guard let authContext = await auth.authContext() else { return } 73 + try? await client.putNotificationPrefs(current, auth: authContext) 74 + } 75 + } 76 + } 77 + 78 + private extension Binding where Value == NotifPref? { 79 + func defaulted() -> Binding<NotifPref> { 80 + Binding<NotifPref>( 81 + get: { self.wrappedValue ?? .default }, 82 + set: { self.wrappedValue = $0 } 83 + ) 84 + } 85 + } 86 + 87 + #Preview { 88 + NavigationStack { 89 + NotificationSettingsView(client: .preview) 90 + .previewEnvironments() 91 + } 92 + }
+8
Grain/Views/Settings/SettingsView.swift
··· 23 23 } 24 24 } 25 25 26 + Section("Notifications") { 27 + NavigationLink { 28 + NotificationSettingsView(client: client) 29 + } label: { 30 + Label("Notifications", systemImage: "bell") 31 + } 32 + } 33 + 26 34 Section("Moderation") { 27 35 NavigationLink { 28 36 ModerationView(client: client)