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

grain.social/grain-ios

1 collaborator
0
1
main
511 lines · 23.4 KB Raw History
import AuthenticationServices
import SafariServices
import SwiftUI

struct LoginView: View {
    static let legalMarkdown =
        "By signing in you agree to our [Terms](https://grain.social/support/terms), " +
        "[Privacy Policy](https://grain.social/support/privacy), " +
        "and [Community Guidelines](https://grain.social/support/community-guidelines)."

    @Environment(AuthManager.self) private var auth
    @State private var handle = ""
    @State private var isLoading = false
    @State private var isSearching = false
    @State private var errorMessage: String?
    @State private var suggestions: [ActorSuggestion] = []
    @State private var searchTask: Task<Void, Never>?
    @State private var highlightedSuggestionIndex: Int?
    @Namespace private var suggestionHighlightNS
    @State private var safariURL: URL?
    @FocusState private var isInputFocused: Bool

    /// `handle` and `suggestions` seed the field and its results list, which is
    /// the half of this screen that only exists once somebody has typed
    /// something.
    init(handle: String = "", suggestions: [ActorSuggestion] = []) {
        _handle = State(initialValue: handle)
        _suggestions = State(initialValue: suggestions)
    }

    var body: some View {
        GeometryReader { geo in
            let fullHeight = geo.size.height + geo.safeAreaInsets.top + geo.safeAreaInsets.bottom
            ZStack {
                Image("login-bg")
                    .resizable()
                    .scaledToFill()
                    .frame(width: geo.size.width, height: fullHeight)
                    .clipped()
                    .ignoresSafeArea()
                    .allowsHitTesting(false)

                Color.black.opacity(0.4)
                    .ignoresSafeArea()
                    .allowsHitTesting(false)

                ScrollViewReader { proxy in
                    ScrollView {
                        if suggestions.isEmpty {
                            Spacer()
                                .frame(minHeight: 100)
                                .frame(maxHeight: .infinity)
                        } else {
                            Spacer()
                                .frame(height: 60)
                        }

                        // Logo
                        Text("grain")
                            .font(.custom("Syne", size: 44).weight(.heavy))
                            .foregroundStyle(.white)
                            .padding(.bottom, suggestions.isEmpty ? 60 : 20)

                        if let reason = auth.reauthReason, suggestions.isEmpty {
                            HStack(alignment: .top, spacing: 10) {
                                Image(systemName: "exclamationmark.circle.fill")
                                    .font(.body.weight(.medium))
                                    .foregroundStyle(.white)
                                    .accessibilityHidden(true)
                                Text(reason)
                                    .font(.subheadline)
                                    .foregroundStyle(.white)
                                    .multilineTextAlignment(.leading)
                            }
                            .padding(.horizontal, 16)
                            .padding(.vertical, 12)
                            .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
                            .overlay(
                                RoundedRectangle(cornerRadius: 12)
                                    .stroke(.white.opacity(0.25), lineWidth: 1)
                            )
                            .padding(.horizontal, 20)
                            .padding(.bottom, 20)
                        }

                        if suggestions.isEmpty {
                            // Heading
                            Text("Sign in with your atmosphere account")
                                .font(.title3.weight(.semibold))
                                .foregroundStyle(.white)
                                .multilineTextAlignment(.center)
                                .padding(.bottom, 4)

                            AtmosphereLogosMarquee()
                                .padding(.bottom, 16)
                        }

                        // Login card
                        VStack(spacing: 16) {
                            // Handle input
                            HStack(spacing: 10) {
                                Image(systemName: "at")
                                    .font(.body.weight(.medium))
                                    .foregroundStyle(.white.opacity(0.5))
                                    .accessibilityHidden(true)

                                TextField("e.g. user.bsky.social", text: $handle, prompt: Text("e.g. user.bsky.social").foregroundStyle(.white.opacity(0.5)))
                                    .foregroundStyle(.white)
                                    .textContentType(.username)
                                    .autocorrectionDisabled()
                                    .textInputAutocapitalization(.never)
                                    .submitLabel(.go)
                                    .focused($isInputFocused)
                                    .onSubmit(submitFromKeyboard)
                                    .onChange(of: handle) {
                                        searchTask?.cancel()
                                        let query = handle
                                        let trimmed = query.trimmingCharacters(in: .whitespaces)
                                        isSearching = trimmed.count >= 2
                                        searchTask = Task {
                                            try? await Task.sleep(for: .milliseconds(200))
                                            guard !Task.isCancelled else { return }
                                            await searchActors(query: query)
                                        }
                                    }
                                    .onKeyPress(.downArrow) {
                                        moveSuggestionHighlight(by: 1)
                                    }
                                    .onKeyPress(.upArrow) {
                                        moveSuggestionHighlight(by: -1)
                                    }
                                    .onKeyPress(.tab) {
                                        moveSuggestionHighlight(by: 1, wrap: true)
                                    }

                                if isSearching {
                                    ProgressView()
                                        .tint(.white.opacity(0.7))
                                        .scaleEffect(0.8)
                                }
                            }
                            .padding(.horizontal, 16)
                            .padding(.vertical, 14)
                            .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
                            .overlay(
                                RoundedRectangle(cornerRadius: 12)
                                    .stroke(.white.opacity(0.25), lineWidth: 1)
                            )
                            .id("handle-input")

                            // Suggestions
                            if !suggestions.isEmpty {
                                VStack(spacing: 0) {
                                    ForEach(Array(suggestions.enumerated()), id: \.element.id) { index, actor in
                                        let isHighlighted = highlightedSuggestionIndex == index
                                        Button {
                                            handle = actor.handle
                                            suggestions = []
                                            highlightedSuggestionIndex = nil
                                            Task { await login() }
                                        } label: {
                                            HStack(spacing: 10) {
                                                AvatarView(url: actor.avatar, size: 32)

                                                VStack(alignment: .leading, spacing: 1) {
                                                    if let displayName = actor.displayName, !displayName.isEmpty {
                                                        Text(displayName)
                                                            .font(.subheadline.weight(.medium))
                                                            .foregroundStyle(.white)
                                                            .lineLimit(1)
                                                    }
                                                    Text("@\(actor.handle)")
                                                        .font(.caption)
                                                        .foregroundStyle(.white.opacity(0.6))
                                                        .lineLimit(1)
                                                }

                                                Spacer()
                                            }
                                            .padding(.horizontal, 16)
                                            .padding(.vertical, 8)
                                            .background {
                                                if isHighlighted {
                                                    Color.clear
                                                        .glassEffect(
                                                            .regular.tint(Color.accentColor.opacity(0.45)),
                                                            in: .rect(cornerRadius: 12)
                                                        )
                                                        .padding(.horizontal, 6)
                                                        .padding(.vertical, 2)
                                                        .matchedGeometryEffect(id: "suggestion-highlight", in: suggestionHighlightNS)
                                                }
                                            }
                                            .contentShape(Rectangle())
                                        }
                                        .buttonStyle(.plain)
                                        .id(actor.id)

                                        if actor.id != suggestions.last?.id {
                                            Divider()
                                                .background(.white.opacity(0.15))
                                                .padding(.leading, 58)
                                        }
                                    }
                                }
                                .padding(.vertical, 4)
                                .background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
                                .overlay(
                                    RoundedRectangle(cornerRadius: 12)
                                        .stroke(.white.opacity(0.2), lineWidth: 1)
                                )
                                .id("suggestions")
                            }

                            // Sign in button
                            Button {
                                Task { await login() }
                            } label: {
                                Group {
                                    if isLoading {
                                        ProgressView()
                                            .tint(.black)
                                    } else {
                                        Text("Sign in")
                                            .font(.body.weight(.semibold))
                                    }
                                }
                                .frame(maxWidth: .infinity)
                                .padding(.vertical, 14)
                            }
                            .background(.white, in: RoundedRectangle(cornerRadius: 12))
                            .foregroundStyle(.black)
                            .disabled(handle.isEmpty || isLoading)
                            .opacity(handle.isEmpty ? 0.4 : 1)

                            // Create account button
                            Button {
                                Task { await createAccount() }
                            } label: {
                                Text("Create account")
                                    .font(.body.weight(.semibold))
                                    .frame(maxWidth: .infinity)
                                    .padding(.vertical, 14)
                            }
                            .background(.white.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
                            .overlay(
                                RoundedRectangle(cornerRadius: 12)
                                    .stroke(.white.opacity(0.25), lineWidth: 1)
                            )
                            .foregroundStyle(.white)
                            .disabled(isLoading)
                            // Legal links
                            Text(LocalizedStringKey(Self.legalMarkdown))
                                .font(.caption2)
                                .foregroundStyle(.white.opacity(0.5))
                                .tint(.white.opacity(0.7))
                                .multilineTextAlignment(.center)

                            Button("What's an atmosphere account?") {
                                safariURL = URL(string: "https://atmosphereaccount.com/")
                            }
                            .font(.footnote)
                            .foregroundStyle(.white.opacity(0.5))
                            .padding(.top, 4)
                        }
                        .padding(24)

                        if let errorMessage {
                            Text(errorMessage)
                                .font(.caption)
                                .foregroundStyle(.red)
                                .multilineTextAlignment(.center)
                                .padding(.horizontal, 24)
                        }

                        Spacer()
                            .frame(height: 60)
                    }
                    .frame(minHeight: geo.size.height)
                    .onChange(of: suggestions) { _, newValue in
                        if newValue.isEmpty {
                            highlightedSuggestionIndex = nil
                        } else if let idx = highlightedSuggestionIndex, idx >= newValue.count {
                            highlightedSuggestionIndex = newValue.count - 1
                        }
                        if !newValue.isEmpty {
                            withAnimation {
                                proxy.scrollTo("suggestions", anchor: .bottom)
                            }
                        }
                    }
                    .onChange(of: isInputFocused) { _, focused in
                        if focused {
                            withAnimation {
                                proxy.scrollTo("handle-input", anchor: .center)
                            }
                        }
                    }
                    .onChange(of: highlightedSuggestionIndex) { _, newIndex in
                        guard let idx = newIndex, suggestions.indices.contains(idx) else { return }
                        withAnimation(.easeInOut(duration: 0.2)) {
                            proxy.scrollTo(suggestions[idx].id, anchor: .center)
                        }
                    }
                }
                .scrollDismissesKeyboard(.interactively)
                .scrollIndicators(.hidden)
                .environment(\.openURL, OpenURLAction { url in
                    safariURL = url
                    return .handled
                })
                .sheet(item: $safariURL) { url in
                    LoginSafariView(url: url)
                        .ignoresSafeArea()
                }
            } // ScrollViewReader
        }
    }

    /// Move the keyboard highlight up or down the suggestions list.
    /// `wrap` (used for Tab) jumps back to the first item after the last.
    /// Up-arrow off the first item deselects so the TextField regains focus.
    /// The state change is wrapped in `withAnimation` so the matched-geometry
    /// highlight layer physically slides between rows.
    private func moveSuggestionHighlight(by delta: Int, wrap: Bool = false) -> KeyPress.Result {
        guard !suggestions.isEmpty else { return .ignored }
        let last = suggestions.count - 1
        withAnimation(.spring(response: 0.3, dampingFraction: 0.82)) {
            if let current = highlightedSuggestionIndex {
                let next = current + delta
                if next < 0 {
                    highlightedSuggestionIndex = nil
                } else if next > last {
                    highlightedSuggestionIndex = wrap ? 0 : last
                } else {
                    highlightedSuggestionIndex = next
                }
            } else {
                highlightedSuggestionIndex = delta >= 0 ? 0 : last
            }
        }
        return .handled
    }

    /// Submit either the highlighted suggestion or the raw handle text,
    /// mirroring the behavior of clicking a row vs. tapping Sign In.
    private func submitFromKeyboard() {
        if let idx = highlightedSuggestionIndex, suggestions.indices.contains(idx) {
            handle = suggestions[idx].handle
            suggestions = []
            highlightedSuggestionIndex = nil
            Task { await login() }
        } else if !handle.isEmpty {
            Task { await login() }
        }
    }

    private func createAccount() async {
        isLoading = true
        errorMessage = nil
        do {
            try await auth.login(createAccount: true)
        } catch XRPCError.authorizationDenied, ASWebAuthenticationSessionError.canceledLogin {
            // Backing out of the sign-in sheet isn't a failure worth reporting.
        } catch let XRPCError.httpError(statusCode, body) {
            let bodyStr = body.flatMap { String(data: $0, encoding: .utf8) } ?? "no body"
            errorMessage = "HTTP \(statusCode): \(bodyStr)"
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }

    private func login() async {
        isLoading = true
        errorMessage = nil
        suggestions = []
        do {
            try await auth.login(handle: handle)
        } catch XRPCError.authorizationDenied, ASWebAuthenticationSessionError.canceledLogin {
            // Backing out of the sign-in sheet isn't a failure worth reporting.
        } catch let XRPCError.httpError(statusCode, body) {
            let bodyStr = body.flatMap { String(data: $0, encoding: .utf8) } ?? "no body"
            errorMessage = "HTTP \(statusCode): \(bodyStr)"
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }

    private func searchActors(query: String) async {
        defer { isSearching = false }
        let trimmed = query.trimmingCharacters(in: .whitespaces)
        guard trimmed.count >= 2 else {
            suggestions = []
            return
        }

        var components = URLComponents(url: AuthManager.serverURL.appendingPathComponent("xrpc/social.grain.actor.searchActorsTypeahead"), resolvingAgainstBaseURL: false)!
        components.queryItems = [
            URLQueryItem(name: "q", value: trimmed),
            URLQueryItem(name: "limit", value: "5"),
        ]
        guard let url = components.url else { return }

        guard let (data, _) = try? await NetworkEnvironment.session.data(from: url),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
              let actors = json["actors"] as? [[String: Any]]
        else {
            return
        }

        suggestions = actors.compactMap { dict in
            guard let handle = dict["handle"] as? String else { return nil }
            return ActorSuggestion(
                handle: handle,
                displayName: dict["displayName"] as? String,
                avatar: dict["avatar"] as? String
            )
        }
    }
}

struct ActorSuggestion: Identifiable, Equatable {
    let handle: String
    let displayName: String?
    let avatar: String?
    var id: String {
        handle
    }
}

// MARK: - Atmosphere Logos Marquee

private struct AtmosphereApp: Identifiable {
    let name: String
    let logo: String
    var id: String {
        name
    }
}

private struct AtmosphereLogosMarquee: View {
    private static let apps: [AtmosphereApp] = [
        .init(name: "Bluesky", logo: "atmo-bluesky"),
        .init(name: "Tangled", logo: "atmo-tangled"),
        .init(name: "Anisota", logo: "atmo-anisota"),
        .init(name: "Beacon Bits", logo: "atmo-beacon-bits"),
        .init(name: "Eurosky", logo: "atmo-eurosky"),
        .init(name: "Flashes", logo: "atmo-flashes"),
        .init(name: "Gander", logo: "atmo-gander"),
        .init(name: "Germ", logo: "atmo-germ"),
        .init(name: "Leaflet", logo: "atmo-leaflet"),
        .init(name: "Northsky", logo: "atmo-northsky"),
        .init(name: "Offprint", logo: "atmo-offprint"),
        .init(name: "Pckt", logo: "atmo-pckt"),
        .init(name: "Plyr", logo: "atmo-plyr"),
        .init(name: "Popfeed", logo: "atmo-popfeed"),
        .init(name: "Blento", logo: "atmo-blento"),
        .init(name: "Semble", logo: "atmo-semble"),
        .init(name: "Skylight", logo: "atmo-skylight"),
        .init(name: "Blacksky", logo: "atmo-blacksky"),
        .init(name: "Spark", logo: "atmo-spark"),
        .init(name: "Stream Place", logo: "atmo-stream-place"),
    ]

    private let logoSize: CGFloat = 40
    private let itemSpacing: CGFloat = 12
    private let speed: CGFloat = 25

    private static let uiImages: [UIImage] = apps.compactMap { UIImage(named: $0.logo) }

    var body: some View {
        TimelineView(.animation) { (timeline: TimelineViewDefaultContext) in
            let now = timeline.date.timeIntervalSinceReferenceDate
            Canvas { ctx, size in
                let images = Self.uiImages
                let totalWidth = CGFloat(images.count) * (logoSize + itemSpacing)
                let scrollOffset = (CGFloat(now) * speed).truncatingRemainder(dividingBy: totalWidth)

                for i in 0 ..< images.count * 2 {
                    let idx = i % images.count
                    let x = CGFloat(i) * (logoSize + itemSpacing) - scrollOffset
                    guard x + logoSize > 0, x < size.width else { continue }
                    let rect = CGRect(x: x, y: (size.height - logoSize) / 2, width: logoSize, height: logoSize)
                    ctx.drawLayer { layerCtx in
                        let clipPath = RoundedRectangle(cornerRadius: 10).path(in: rect)
                        layerCtx.clip(to: clipPath)
                        layerCtx.draw(Image(uiImage: images[idx]), in: rect)
                    }
                }
            }
            .frame(height: 44)
        }
        .clipped()
    }
}

private struct LoginSafariView: UIViewControllerRepresentable {
    let url: URL
    func makeUIViewController(context _: Context) -> SFSafariViewController {
        SFSafariViewController(url: url)
    }

    func updateUIViewController(_: SFSafariViewController, context _: Context) {}
}

#Preview {
    LoginView()
        .previewEnvironments()
}