~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
544 lines · 19.2 KB Raw History
import Nuke
import NukeUI
import os
import SwiftUI

private let notificationsLaunchSignposter = OSSignposter(subsystem: "social.grain.grain", category: "AppLaunch")

struct NotificationsView: View {
    @Environment(AuthManager.self) private var auth
    var viewModel: NotificationsViewModel
    @Environment(Router.self) private var router
    @State private var cardStoryAuthor: GrainStoryAuthor?
    @State private var selectedStory: GrainStory?
    @State private var selectedGroup: GroupedNotification?
    let client: XRPCClient

    init(client: XRPCClient, viewModel: NotificationsViewModel) {
        self.client = client
        self.viewModel = viewModel
        notificationsLaunchSignposter.emitEvent("NotificationsViewInit")
    }

    var body: some View {
        @Bindable var router = router
        let _ = notificationsLaunchSignposter.emitEvent("NotificationsViewBodyBegin")
        NavigationStack(path: $router.path) {
            NotificationListContent(
                viewModel: viewModel,
                client: client,
                authContext: { await auth.authContext() },
                onProfileTap: { router.push(.profile(did: $0)) },
                onGalleryTap: { router.push(.gallery(uri: $0)) },
                onStoryAuthorTap: { cardStoryAuthor = $0 },
                onStoryTap: { selectedStory = $0 },
                onGroupTap: { selectedGroup = $0 }
            )
            .navigationTitle("Notifications")
            .navigationDestination(for: Route.self) { route in
                RouteDestination(route: route, client: client)
            }
            .navigationDestination(item: $selectedGroup) { group in
                GroupedAuthorsView(
                    group: group,
                    client: client
                )
            }
            .fullScreenCover(item: $cardStoryAuthor) { author in
                StoryViewer(
                    authors: [author],
                    client: client,
                    onProfileTap: { did in
                        cardStoryAuthor = nil
                        router.push(.profile(did: did))
                    },
                    onDismiss: { cardStoryAuthor = nil }
                )
                .environment(auth)
            }
            .fullScreenCover(item: $selectedStory) { story in
                StoryViewer(
                    authors: [GrainStoryAuthor(
                        profile: story.creator,
                        storyCount: 1,
                        latestAt: story.createdAt
                    )],
                    initialStories: [story],
                    client: client,
                    onProfileTap: { did in
                        selectedStory = nil
                        router.push(.profile(did: did))
                    },
                    onDismiss: { selectedStory = nil }
                )
                .environment(auth)
            }
            .task {
                if viewModel.notifications.isEmpty || viewModel.unseenCount > 0 {
                    await viewModel.loadInitial(auth: auth.authContext())
                }
                if viewModel.unseenCount > 0 {
                    await viewModel.markAsSeen(auth: auth.authContext())
                }
            }
        }
    }
}

// MARK: - List Content (no @Environment — auth passed as closure)

struct NotificationListContent: View {
    let viewModel: NotificationsViewModel
    let client: XRPCClient
    let authContext: () async -> AuthContext?
    let onProfileTap: (String) -> Void
    let onGalleryTap: (String) -> Void
    let onStoryAuthorTap: (GrainStoryAuthor) -> Void
    let onStoryTap: (GrainStory) -> Void
    let onGroupTap: (GroupedNotification) -> Void

    var body: some View {
        List {
            ForEach(viewModel.grouped) { group in
                NotificationRowContainer(
                    group: group,
                    client: client,
                    authContext: authContext,
                    onProfileTap: onProfileTap,
                    onGalleryTap: onGalleryTap,
                    onStoryAuthorTap: onStoryAuthorTap,
                    onStoryTap: onStoryTap,
                    onGroupTap: onGroupTap
                )
                .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
                .onAppear {
                    if group.id == viewModel.grouped.last?.id {
                        Task { await viewModel.loadMore(auth: authContext()) }
                    }
                }
            }
            .listRowSeparator(.visible)
            .listRowSeparatorTint(Color(.separator))
            .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
        }
        .listStyle(.plain)
        .refreshable {
            await viewModel.loadInitial(auth: authContext())
        }
    }
}

// MARK: - Row Container (no @Environment — auth passed as closure)

struct NotificationRowContainer: View {
    let group: GroupedNotification
    let client: XRPCClient
    let authContext: () async -> AuthContext?
    let onProfileTap: (String) -> Void
    let onGalleryTap: (String) -> Void
    let onStoryAuthorTap: (GrainStoryAuthor) -> Void
    let onStoryTap: (GrainStory) -> Void
    let onGroupTap: (GroupedNotification) -> Void

    var body: some View {
        if group.isGrouped {
            GroupedNotificationRow(
                group: group,
                onProfileTap: onProfileTap,
                onSubjectTap: { handleTap(group.notification) },
                onGroupTap: { onGroupTap(group) }
            )
        } else {
            SingleNotificationRow(
                notification: group.notification,
                onProfileTap: onProfileTap,
                onSubjectTap: { handleTap(group.notification) }
            )
            .contentShape(Rectangle())
            .onTapGesture {
                handleTap(group.notification)
            }
            .swipeActions(edge: .leading) {
                Button {
                    onProfileTap(group.notification.author.did)
                } label: {
                    Label("Profile", systemImage: "person")
                }
            }
        }
    }

    private func handleTap(_ notification: GrainNotification) {
        if notification.reasonType == .follow {
            onProfileTap(notification.author.did)
        } else if notification.reasonType == .commentFavorite {
            // Navigate to the parent gallery or story
            if let galleryUri = notification.galleryUri {
                onGalleryTap(galleryUri)
            } else if let storyUri = notification.storyUri {
                Task {
                    if let story = try? await client.getStory(uri: storyUri, auth: authContext()).story {
                        onStoryTap(story)
                    }
                }
            }
        } else if notification.reasonType == .storyFavorite || notification.reasonType == .storyComment {
            if let storyUri = notification.storyUri {
                Task {
                    if let story = try? await client.getStory(uri: storyUri, auth: authContext()).story {
                        onStoryTap(story)
                    }
                }
            } else {
                onProfileTap(notification.author.did)
            }
        } else if let galleryUri = notification.galleryUri {
            onGalleryTap(galleryUri)
        }
    }
}

// MARK: - Reason Icon

struct ReasonIcon: View {
    let reason: NotificationReason

    private var iconName: String {
        switch reason {
        case .galleryFavorite, .storyFavorite, .commentFavorite: "heart.fill"
        case .follow: "person.fill.badge.plus"
        case .galleryComment, .storyComment: "text.bubble.fill"
        case .reply: "arrowshape.turn.up.backward.fill"
        case .galleryCommentMention, .galleryMention: "at"
        case .unknown: "bell.fill"
        }
    }

    private var label: String {
        switch reason {
        case .galleryFavorite, .storyFavorite, .commentFavorite: "Favorited"
        case .follow: "Followed"
        case .galleryComment, .storyComment: "Commented"
        case .reply: "Replied"
        case .galleryCommentMention, .galleryMention: "Mentioned"
        case .unknown: "Notification"
        }
    }

    private var iconColor: Color {
        switch reason {
        case .galleryFavorite, .storyFavorite, .commentFavorite: .heart
        default: .accentColor
        }
    }

    var body: some View {
        Image(systemName: iconName)
            .foregroundStyle(iconColor)
            .font(.system(size: 18))
            .frame(width: 20)
            .accessibilityLabel(label)
    }
}

// MARK: - Grouped Notification Row

struct GroupedNotificationRow: View {
    let group: GroupedNotification
    var onProfileTap: ((String) -> Void)?
    var onSubjectTap: (() -> Void)?
    var onGroupTap: (() -> Void)?

    private var thumb: String? {
        group.notification.galleryThumb ?? group.notification.storyThumb
    }

    private var isStoryThumb: Bool {
        group.notification.galleryThumb == nil && group.notification.storyThumb != nil
    }

    var body: some View {
        HStack(alignment: .top, spacing: 10) {
            ReasonIcon(reason: group.notification.reasonType)
                .frame(height: 38)

            VStack(alignment: .leading, spacing: 6) {
                HStack(spacing: 0) {
                    OverlappingAvatarsView(
                        authors: Array(group.allAuthors.prefix(5)),
                        size: 38,
                        overlap: 8,
                        onProfileTap: onProfileTap
                    )
                    if group.authorCount > 5 {
                        Button {
                            onGroupTap?()
                        } label: {
                            Text("+\(group.authorCount - 5)")
                                .font(.caption2.bold())
                                .foregroundStyle(.secondary)
                                .padding(.horizontal, 6)
                                .padding(.vertical, 2)
                                .background(Color(.tertiarySystemFill), in: Capsule())
                        }
                        .buttonStyle(.plain)
                        .padding(.leading, 6)
                    }
                    Spacer(minLength: 0)
                }

                let name = group.notification.author.displayName ?? group.notification.author.handle
                let othersCount = group.authorCount - 1
                let others = othersCount == 1 ? "1 other" : "\(othersCount) others"
                Button {
                    onSubjectTap?()
                } label: {
                    VStack(alignment: .leading, spacing: 4) {
                        Text("\(Text(name).bold()) and \(others) \(reasonText) \(Text(DateFormatting.relativeTime(group.notification.createdAt)).foregroundStyle(.secondary))")
                            .font(.subheadline)
                            .foregroundStyle(.primary)
                        if group.notification.reasonType == .galleryFavorite,
                           let title = group.notification.galleryTitle
                        {
                            Text(title)
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                                .lineLimit(1)
                        }
                    }
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.trailing, thumb != nil ? 54 : 0)
                }
                .buttonStyle(.plain)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .overlay(alignment: .trailing) {
            if let thumb {
                Button { onSubjectTap?() } label: {
                    CachedThumbnailView(url: thumb, size: 44, portrait: isStoryThumb)
                }
                .buttonStyle(.plain)
            }
        }
    }

    private var reasonText: String {
        switch group.notification.reasonType {
        case .galleryFavorite: "favorited your gallery"
        case .commentFavorite: "favorited your comment"
        case .storyFavorite: "favorited your story"
        case .follow: "followed you"
        default: ""
        }
    }
}

// MARK: - Single Notification Row

struct SingleNotificationRow: View {
    let notification: GrainNotification
    var onProfileTap: ((String) -> Void)?
    var onSubjectTap: (() -> Void)?

    private var thumb: String? {
        notification.galleryThumb ?? notification.storyThumb
    }

    private var isStoryThumb: Bool {
        notification.galleryThumb == nil && notification.storyThumb != nil
    }

    var body: some View {
        HStack(alignment: .top, spacing: 10) {
            ReasonIcon(reason: notification.reasonType)
                .frame(height: 38)

            VStack(alignment: .leading, spacing: 6) {
                AvatarView(url: notification.author.avatar, size: 38, animated: false)
                    .onTapGesture {
                        onProfileTap?(notification.author.did)
                    }

                VStack(alignment: .leading, spacing: 2) {
                    Text("\(Text(notification.author.displayName ?? notification.author.handle).bold()) \(reasonText) \(Text(DateFormatting.relativeTime(notification.createdAt)).foregroundStyle(.secondary))")
                        .font(.subheadline)
                        .foregroundStyle(.primary)
                    if notification.reasonType == .galleryFavorite,
                       let title = notification.galleryTitle
                    {
                        Text(title)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                            .lineLimit(1)
                    }
                    if let commentText = notification.commentText {
                        Text(commentText)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                            .lineLimit(3)
                    }
                }
                .padding(.trailing, thumb != nil ? 54 : 0)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .overlay(alignment: .trailing) {
            if let thumb {
                Button { onSubjectTap?() } label: {
                    CachedThumbnailView(url: thumb, size: 44, portrait: isStoryThumb)
                }
                .buttonStyle(.plain)
            }
        }
    }

    private var reasonText: String {
        switch notification.reasonType {
        case .galleryFavorite: "favorited your gallery"
        case .galleryComment: "commented on your gallery"
        case .galleryCommentMention: "mentioned you in a comment"
        case .galleryMention: "mentioned you in a gallery"
        case .commentFavorite: "favorited your comment"
        case .storyFavorite: "favorited your story"
        case .storyComment: "commented on your story"
        case .reply: "replied to your comment"
        case .follow: "followed you"
        case .unknown: ""
        }
    }
}

// MARK: - Overlapping Avatars

struct OverlappingAvatarsView: View {
    let authors: [GrainProfile]
    let size: CGFloat
    let overlap: CGFloat
    var onProfileTap: ((String) -> Void)?

    private var step: CGFloat {
        size - overlap
    }

    var body: some View {
        HStack(spacing: -overlap) {
            ForEach(Array(authors.enumerated()), id: \.element.did) { i, author in
                Button {
                    onProfileTap?(author.did)
                } label: {
                    AvatarView(url: author.avatar, size: size, animated: false)
                }
                .buttonStyle(.plain)
                .zIndex(Double(authors.count - i))
            }
        }
        .fixedSize()
    }
}

// MARK: - Cached Thumbnail (sync cache read, no LazyImage)

struct CachedThumbnailView: View {
    let url: String
    let size: CGFloat
    var portrait: Bool = false

    private var width: CGFloat {
        size
    }

    private var height: CGFloat {
        portrait ? size * 4 / 3 : size
    }

    private var imageURL: URL? {
        URL(string: url)
    }

    var body: some View {
        LazyImage(url: imageURL) { state in
            if let image = state.image {
                image
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } else {
                Rectangle().fill(.quaternary)
            }
        }
        .processors([ImageProcessors.Resize(size: CGSize(width: width * UIScreen.main.scale, height: height * UIScreen.main.scale), contentMode: .aspectFill)])
        .frame(width: width, height: height)
        .clipShape(.rect(cornerRadius: 6))
    }
}

// MARK: - Grouped Authors Detail View

struct GroupedAuthorsView: View {
    let group: GroupedNotification
    let client: XRPCClient

    private var title: String {
        let count = group.authorCount
        switch group.notification.reasonType {
        case .galleryFavorite: return "\(count) Favorites"
        case .commentFavorite: return "\(count) Favorites"
        case .storyFavorite: return "\(count) Favorites"
        case .follow: return "\(count) Followers"
        default: return "\(count) People"
        }
    }

    var body: some View {
        List {
            ForEach(group.allAuthors, id: \.did) { author in
                NavigationLink {
                    ProfileView(client: client, did: author.did)
                } label: {
                    HStack(alignment: .center, spacing: 14) {
                        AvatarView(url: author.avatar, size: 50, animated: false)

                        VStack(alignment: .leading, spacing: 2) {
                            HStack(spacing: 4) {
                                if let displayName = author.displayName, !displayName.isEmpty {
                                    Text(displayName)
                                        .font(.body.weight(.semibold))
                                        .lineLimit(1)
                                }
                                Text("@\(author.handle)")
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                                    .lineLimit(1)
                            }
                            if let desc = author.description, !desc.isEmpty {
                                Text(desc)
                                    .font(.subheadline)
                                    .foregroundStyle(.secondary)
                                    .lineLimit(2)
                            }
                        }
                        Spacer()
                    }
                    .padding(.vertical, 4)
                }
                .buttonStyle(.plain)
                .listRowSeparator(.visible)
            }
        }
        .listStyle(.plain)
        .navigationTitle(title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

#Preview {
    let client = XRPCClient.preview
    let vm = NotificationsViewModel(client: client)
    vm.notifications = PreviewData.notifications
    vm.grouped = GroupedNotification.group(vm.notifications)
    vm.unseenCount = 3
    return NotificationsView(client: client, viewModel: vm)
        .previewEnvironments()
        .grainPreview()
        .frame(maxHeight: .infinity, alignment: .top)
}