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

grain.social/grain-ios

1 collaborator
0
1
main
163 lines · 7.1 KB Raw History
import SwiftUI

struct StoryStripView: View {
    @Environment(ViewedStoryStorage.self) private var viewedStories
    let authors: [GrainStoryAuthor]
    let userDid: String?
    let userAvatar: String?
    var sortVersion: Int = 0
    let onAuthorTap: (GrainStoryAuthor, Int) -> Void
    var onAuthorLongPress: ((String) -> Void)?
    let onCreateTap: () -> Void

    private let avatarSize: CGFloat = 68

    @State private var sorted: [GrainStoryAuthor] = []
    @State private var liftedDids: Set<String> = []

    private var ownAuthor: GrainStoryAuthor? {
        authors.first(where: { $0.profile.did == userDid })
    }

    private func computeSorted() -> [GrainStoryAuthor] {
        let others = authors.filter { $0.profile.did != userDid }
        let unviewed = others.filter { !viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) }
        let viewed = others.filter { viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) }
        return unviewed + viewed
    }

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 16) {
                // Your story
                VStack(spacing: 4) {
                    ZStack(alignment: .bottomTrailing) {
                        StoryRingView(hasStory: ownAuthor != nil, viewed: false, size: avatarSize) {
                            AvatarView(url: userAvatar, size: avatarSize)
                        }
                        .padding(4)
                        Image(systemName: "plus.circle.fill")
                            .font(.system(size: 18))
                            .foregroundStyle(.white, Color.accentColor)
                            .background(Circle().fill(Color(.systemBackground)).padding(-1))
                            .offset(x: -1, y: -1)
                            .accessibilityHidden(true)
                    }
                    Text("Your story")
                        .font(.caption2)
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                }
                .accessibilityElement(children: .ignore)
                .accessibilityLabel(ownAuthor != nil ? "View your story" : "Create a story")
                .accessibilityAddTraits(.isButton)
                .onTapGesture {
                    if let own = ownAuthor {
                        onAuthorTap(own, 0)
                    } else {
                        onCreateTap()
                    }
                }
                .profileContextMenu(
                    handle: nil,
                    hasStory: ownAuthor != nil,
                    onViewStory: ownAuthor.map { own in { onAuthorTap(own, 0) } },
                    onAddStory: { onCreateTap() },
                    showSharingActions: false
                ) {
                    StoryRingView(hasStory: ownAuthor != nil, viewed: false, size: 96) {
                        AvatarView(url: userAvatar, size: 96)
                    }
                    .padding(6)
                }

                ForEach(sorted, id: \.id) { author in
                    let isViewed = viewedStories.hasViewedAll(authorDid: author.profile.did, latestAt: author.latestAt)
                    let isLifted = liftedDids.contains(author.profile.did)
                    VStack(spacing: 4) {
                        StoryRingView(hasStory: true, viewed: isViewed, size: avatarSize) {
                            AvatarView(url: author.profile.avatar, size: avatarSize)
                        }
                        .padding(4)
                        .profileContextMenu(
                            handle: author.profile.handle,
                            hasStory: true,
                            onViewProfile: { onAuthorLongPress?(author.profile.did) },
                            onViewStory: { onAuthorTap(author, 0) }
                        ) {
                            StoryRingView(hasStory: true, viewed: isViewed, size: 96) {
                                AvatarView(url: author.profile.avatar, size: 96)
                            }
                            .padding(6)
                        }
                        Text(author.profile.displayName ?? author.profile.handle)
                            .font(.caption2)
                            .foregroundStyle(.secondary)
                            .lineLimit(1)
                            .frame(width: avatarSize + 8)
                    }
                    .scaleEffect(isLifted ? 1.03 : 1.0)
                    .offset(y: isLifted ? -3 : 0)
                    .zIndex(isViewed ? 0 : 1)
                    .accessibilityElement(children: .ignore)
                    .accessibilityLabel("\(author.profile.displayName ?? author.profile.handle)'s story")
                    .accessibilityAddTraits(.isButton)
                    .onTapGesture { onAuthorTap(author, 0) }
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 8)
        }
        .scrollClipDisabled()
        .onAppear { sorted = computeSorted() }
        .onChange(of: authors.map(\.id)) { sorted = computeSorted() }
        .onChange(of: sortVersion) {
            let newSorted = computeSorted()

            // Find where old and new order diverge — that's the gap
            let oldIds = sorted.map(\.profile.did)
            let newIds = newSorted.map(\.profile.did)

            withAnimation(.smooth(duration: 0.5)) {
                sorted = newSorted
            }

            // Skip wave if order didn't change
            guard oldIds != newIds,
                  let gapIndex = zip(oldIds, newIds).enumerated().first(where: { $1.0 != $1.1 })?.offset
            else { return }

            // Wave follows the read card as it slides right past unreads
            let unreadCount = newSorted.count(where: { !viewedStories.hasViewedAll(authorDid: $0.profile.did, latestAt: $0.latestAt) })
            let travelDistance = max(unreadCount - gapIndex, 1)
            for (index, author) in newSorted.enumerated() {
                guard !viewedStories.hasViewedAll(authorDid: author.profile.did, latestAt: author.latestAt) else { continue }
                guard index >= gapIndex else { continue }
                let did = author.profile.did
                let progress = Double(index - gapIndex) / Double(travelDistance)
                let delay = progress * 0.4
                DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                    withAnimation(.spring(response: 0.2, dampingFraction: 0.5)) {
                        _ = liftedDids.insert(did)
                    }
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + delay + 0.18) {
                    withAnimation(.spring(response: 0.25, dampingFraction: 0.7)) {
                        _ = liftedDids.remove(did)
                    }
                }
            }
        }
    }
}

#Preview {
    StoryStripView(
        authors: PreviewData.storyAuthors,
        userDid: "did:plc:prevuser1",
        userAvatar: nil,
        onAuthorTap: { _, _ in },
        onCreateTap: {}
    )
    .previewEnvironments()
}