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

grain.social/grain-ios

1 collaborator
0
1
main
363 lines · 13.0 KB Raw History
import NukeUI
import SwiftUI

enum FollowListMode: Hashable {
    case followers
    case following
    case knownFollowers
    /// Everyone who favorited a gallery. `did` is unused in this mode — the
    /// gallery URI carries the subject.
    case galleryFavorites(String)
}

/// Navigation payload for a gallery's favorites list. A distinct type keeps it
/// from colliding with the other `String?` destinations a feed view registers.
struct FavoritesDestination: Hashable, Identifiable {
    let galleryUri: String
    var id: String {
        galleryUri
    }
}

struct FollowListView: View {
    @Environment(AuthManager.self) private var auth
    let client: XRPCClient
    /// The profile whose graph is being listed. Unused for `.galleryFavorites`.
    var did: String = ""
    let mode: FollowListMode
    @State private var items: [FollowListItem] = []
    @State private var cursor: String?
    @State private var totalCount: Int?
    @State private var isLoading = false
    @State private var hasLoaded = false
    @State private var selectedProfileDid: String?
    @State private var cardStoryAuthor: GrainStoryAuthor?
    @Environment(StoryStatusCache.self) private var storyStatusCache
    @Environment(ViewedStoryStorage.self) private var viewedStories

    private var title: String {
        switch mode {
        case .followers: "Followers"
        case .following: "Following"
        case .knownFollowers: "Followers you know"
        case .galleryFavorites: "Favorited by"
        }
    }

    private var displayCount: String {
        "\(totalCount ?? items.count) \(title.lowercased())"
    }

    private var emptyTitle: String {
        switch mode {
        case .followers, .knownFollowers: "No followers"
        case .following: "No following"
        case .galleryFavorites: "No favorites"
        }
    }

    private var emptyIcon: String {
        switch mode {
        case .followers, .knownFollowers: "person.2"
        case .following: "person.badge.plus"
        case .galleryFavorites: "heart"
        }
    }

    private var emptyDescription: String {
        switch mode {
        case .followers: "No one is following this account yet."
        case .following: "This account isn't following anyone yet."
        case .knownFollowers: "None of the people you follow are following this account."
        case .galleryFavorites: "No one has favorited this gallery yet."
        }
    }

    var body: some View {
        Group {
            if !hasLoaded, isLoading {
                ProgressView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            } else if hasLoaded, items.isEmpty {
                ContentUnavailableView(
                    emptyTitle,
                    systemImage: emptyIcon,
                    description: Text(emptyDescription)
                )
            } else {
                List {
                    ForEach(items) { item in
                        Button {
                            selectedProfileDid = item.did
                        } label: {
                            rowContent(item: item)
                        }
                        .buttonStyle(.plain)
                        .listRowSeparator(.visible)
                        .onAppear {
                            if item.id == items.last?.id {
                                Task { await loadMore() }
                            }
                        }
                    }

                    if isLoading {
                        HStack {
                            Spacer()
                            ProgressView()
                            Spacer()
                        }
                    }
                }
                .listStyle(.plain)
                .refreshable {
                    await reload()
                }
            }
        }
        .navigationTitle(title)
        .navigationDestination(item: $selectedProfileDid) { did in
            ProfileView(client: client, did: did)
        }
        .fullScreenCover(item: $cardStoryAuthor) { author in
            StoryViewer(
                authors: [author],
                client: client,
                onProfileTap: { did in
                    cardStoryAuthor = nil
                    selectedProfileDid = did
                },
                onDismiss: { cardStoryAuthor = nil }
            )
            .environment(auth)
        }
        .task {
            await reload()
        }
        .onAppear {
            if hasLoaded {
                Task { await reload() }
            }
        }
    }

    private func rowContent(item: FollowListItem) -> some View {
        HStack(alignment: .center, spacing: 14) {
            StoryRingView(hasStory: storyStatusCache.hasStory(for: item.did), viewed: item.did != auth.userDID && viewedStories.hasViewedAll(did: item.did, storyStatusCache: storyStatusCache), size: 50) {
                AvatarView(url: item.avatar, size: 50)
            }
            .onTapGesture {
                if let author = storyStatusCache.author(for: item.did) {
                    cardStoryAuthor = author
                } else {
                    selectedProfileDid = item.did
                }
            }
            .profileContextMenu(
                handle: item.handle,
                hasStory: storyStatusCache.hasStory(for: item.did),
                onViewProfile: { selectedProfileDid = item.did },
                onViewStory: {
                    if let author = storyStatusCache.author(for: item.did) {
                        cardStoryAuthor = author
                    }
                }
            )
            VStack(alignment: .leading, spacing: 2) {
                HStack(spacing: 4) {
                    if let displayName = item.displayName, !displayName.isEmpty {
                        Text(displayName)
                            .font(.body.weight(.semibold))
                            .lineLimit(1)
                    }
                    if let handle = item.handle {
                        Text("@\(handle)")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                            .lineLimit(1)
                    }
                }
                if let desc = item.description, !desc.isEmpty {
                    Text(desc)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                        .lineLimit(2)
                }
            }
            Spacer()
        }
        .padding(.vertical, 4)
    }

    private func reload() async {
        cursor = nil
        do {
            switch mode {
            case .followers:
                let response = try await client.getFollowers(actor: did, cursor: nil, auth: auth.authContext())
                items = (response.items ?? []).map { FollowListItem(from: $0) }
                cursor = response.cursor
                totalCount = response.totalCount
            case .following:
                let response = try await client.getFollowing(actor: did, cursor: nil, auth: auth.authContext())
                items = (response.items ?? []).map { FollowListItem(from: $0) }
                cursor = response.cursor
                totalCount = response.totalCount
            case .knownFollowers:
                if auth.userDID != nil {
                    let response = try await client.getKnownFollowers(actor: did, auth: auth.authContext())
                    items = (response.items ?? []).map { FollowListItem(from: $0) }
                    totalCount = items.count
                }
            case let .galleryFavorites(uri):
                let response = try await client.getGalleryFavorites(gallery: uri, cursor: nil, auth: auth.authContext())
                items = (response.items ?? []).map { FollowListItem(from: $0) }
                cursor = response.cursor
                totalCount = response.totalCount
            }
        } catch {
            // keep existing items on error
        }
        hasLoaded = true
    }

    private func loadMore() async {
        guard !isLoading else { return }
        if !items.isEmpty, cursor == nil {
            return
        }
        isLoading = true
        defer { isLoading = false }

        do {
            let existingIDs = Set(items.map(\.id))
            switch mode {
            case .followers:
                let response = try await client.getFollowers(actor: did, cursor: cursor, auth: auth.authContext())
                if let newItems = response.items {
                    let filtered = newItems.filter { !existingIDs.contains($0.did) }
                    items.append(contentsOf: filtered.map { FollowListItem(from: $0) })
                }
                cursor = response.cursor
            case .following:
                let response = try await client.getFollowing(actor: did, cursor: cursor, auth: auth.authContext())
                if let newItems = response.items {
                    let filtered = newItems.filter { !existingIDs.contains($0.did) }
                    items.append(contentsOf: filtered.map { FollowListItem(from: $0) })
                }
                cursor = response.cursor
            case .knownFollowers:
                break // No pagination for known followers
            case let .galleryFavorites(uri):
                let response = try await client.getGalleryFavorites(gallery: uri, cursor: cursor, auth: auth.authContext())
                if let newItems = response.items {
                    let filtered = newItems.filter { !existingIDs.contains($0.did) }
                    items.append(contentsOf: filtered.map { FollowListItem(from: $0) })
                }
                cursor = response.cursor
            }
        } catch {
            // silently fail
        }
    }

    private func toggleFollow(for targetDid: String) async {
        guard let authContext = await auth.authContext(), let repo = auth.userDID else { return }
        guard let index = items.firstIndex(where: { $0.did == targetDid }) else { return }
        let item = items[index]

        if let followUri = item.followingUri, followUri != "pending" {
            let rkey = followUri.split(separator: "/").last.map(String.init) ?? ""
            items[index].followingUri = nil
            do {
                try await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: authContext)
                if mode == .following, did == repo {
                    if let idx = items.firstIndex(where: { $0.did == targetDid }) {
                        items.remove(at: idx)
                    }
                    if let count = totalCount {
                        totalCount = max(0, count - 1)
                    }
                }
            } catch {
                if let idx = items.firstIndex(where: { $0.did == targetDid }) {
                    items[idx].followingUri = followUri
                }
            }
        } else if item.followingUri == nil {
            items[index].followingUri = "pending"
            do {
                let record = AnyCodable([
                    "subject": item.did,
                    "createdAt": DateFormatting.nowISO(),
                ])
                let result = try await client.createRecord(
                    collection: "social.grain.graph.follow",
                    repo: repo,
                    record: record,
                    auth: authContext
                )
                if let idx = items.firstIndex(where: { $0.did == targetDid }) {
                    items[idx].followingUri = result.uri
                }
            } catch {
                if let idx = items.firstIndex(where: { $0.did == targetDid }) {
                    items[idx].followingUri = nil
                }
            }
        }
    }
}

struct FollowListItem: Identifiable {
    let did: String
    var handle: String?
    var displayName: String?
    var description: String?
    var avatar: String?
    var followingUri: String?
    var id: String {
        did
    }

    init(from follower: FollowerItem) {
        did = follower.did
        handle = follower.handle
        displayName = follower.displayName
        description = follower.description
        avatar = follower.avatar
        followingUri = follower.viewer?.following
    }

    init(from favorite: FavoriteItem) {
        did = favorite.did
        handle = favorite.handle
        displayName = favorite.displayName
        description = favorite.description
        avatar = favorite.avatar
        followingUri = favorite.viewer?.following
    }

    init(from following: FollowingItem) {
        did = following.did
        handle = following.handle
        displayName = following.displayName
        description = following.description
        avatar = following.avatar
        followingUri = following.viewer?.following
    }
}

#Preview {
    NavigationStack {
        FollowListView(
            client: .preview,
            did: "did:plc:preview",
            mode: .followers
        )
    }
    .previewEnvironments()
    .preferredColorScheme(.dark)
    .tint(Color.accentColor)
    .frame(maxHeight: .infinity, alignment: .top)
}