Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions App/Features/Documents/PublicUserDocumentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ struct PublicUserDocumentsView: View {
/// The handle to show documents for. A change re-triggers the load.
let username: String

/// Reports the loaded document count to the host, so the profile's
/// Documents stat tile can show a number without a second request for data
/// this column already has (GitHub #44). Defaulted, so the column stays a
/// one-line drop-in for hosts that do not care.
var onCountChange: (Int) -> Void = { _ in }

@Environment(\.appEnvironment) private var environment
@State private var viewModel: PublicUserDocumentsViewModel?

Expand All @@ -43,6 +49,11 @@ struct PublicUserDocumentsView: View {
)
viewModel = model
await model.load(username: username)
// Reported after the load rather than observed, so a failed load
// leaves the tile absent instead of claiming zero.
if model.error == nil {
onCountChange(model.documentsLoaded.count)
}
}
}

Expand Down
54 changes: 51 additions & 3 deletions App/Features/Social/ProfileRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ struct ProfileRootView: View {

@State private var viewModel: ProfileViewModel?

/// The public-document count, reported upward by the documents column.
///
/// Lives here rather than on the view model because the column already
/// fetches the documents for its own rows: asking a second time would be a
/// duplicate request whose answer could disagree with what is on screen.
@State private var documentCount: Int?

/// Mirrors of the view model's session-derived flags, read in the body.
private var isOwnProfile: Bool { viewModel?.isOwnProfile ?? false }
private var isSignedIn: Bool { viewModel?.isSignedIn ?? false }

var body: some View {
NavigationStack {
Group {
Expand All @@ -45,13 +56,23 @@ struct ProfileRootView: View {
// building the view model in `.task` is the canonical
// pattern.
if viewModel == nil, let environment {
viewModel = ProfileViewModel(
let model = ProfileViewModel(
social: environment.social,
relationshipReader: environment.followRelationshipReader,
currentUserID: { [weak environment] in
environment?.currentUserStore.currentUserID
},
currentUsername: { [weak environment] in
environment?.currentUserStore.currentUsername
}
)
viewModel = model
// Land on your own profile (GitHub #44 / G32). The session
// already knows who the user is, so opening Profile to an
// "enter a username" prompt made the one profile everybody
// wants to see the one that took the most typing to reach.
// The lookup field stays, as the way to visit someone else.
await model.loadOwnProfileIfNeeded()
}
}
}
Expand Down Expand Up @@ -155,13 +176,40 @@ struct ProfileRootView: View {
mutuals: mutuals,
followButton: followButton
)
// The five stat tiles the web profile shows (GitHub #44).
// Followers / Following / Posts / Lists come straight off the
// profile payload — the client had been decoding the last two
// and dropping them at the domain boundary. Documents has no
// count on that payload, so the documents column reports its
// own, which is also why the tile and the column can never
// disagree.
ProfileStatTilesView(
profile: profile,
counts: counts,
documentCount: documentCount
)
.padding(.horizontal, 16)

// work-consolidation.md G24 — the documents column the web
// profile has and macOS lacked. Self-contained: it owns its
// view model and its own load, so this stays one line and the
// Social feature learns nothing about `DocumentsServicing`.
Divider()
PublicUserDocumentsView(username: profile.username)
.padding(.horizontal, 16)
PublicUserDocumentsView(
username: profile.username,
onCountChange: { documentCount = $0 }
)
.padding(.horizontal, 16)

// The public-lists column, with a Watch button per row when the
// profile is someone else's (GitHub #44 / G32).
Divider()
PublicUserListsView(
username: profile.username,
isOwnProfile: isOwnProfile,
isSignedIn: isSignedIn
)
.padding(.horizontal, 16)
}
}
}
Expand Down
89 changes: 89 additions & 0 deletions App/Features/Social/ProfileStatTilesView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// ProfileStatTilesView
//
// The five stat tiles the web profile shows — Followers, Following, Posts,
// Documents, Lists (GitHub #44 / G32). macOS showed followers and following in
// the header and nothing else.
//
// Four of the five come straight off `GET /api/users/{username}`, which has
// always returned them:
//
// {"followerCount":1,"followingCount":1,
// "publicMessageCount":31,"publicListCount":0}
//
// `publicMessageCount` and `publicListCount` were decoded by the kit and dropped
// at the domain boundary, so the information was already on the wire and simply
// not carried. The fifth, Documents, has no count on that payload — it is
// reported by the documents column, which fetches the documents anyway.
//
// A tile whose count is unknown is **omitted**, not rendered as zero. "0 posts"
// and "we could not find out how many posts" look identical and mean opposite
// things, and a profile that claims zero when the count call merely failed is
// worse than one that shows four tiles.
//
// Pure SwiftUI; no AppKit. Decision 0003: consumes only `InterlinedDomain`.

import SwiftUI
import InterlinedDomain

struct ProfileStatTilesView: View {

let profile: UserProfile

/// The follow-counts follow-up, when it landed. Preferred over the
/// profile payload's own pair because it is the fresher read — the same
/// precedence the header already applies.
let counts: FollowCounts?

/// The public-document count, from the documents column. `nil` until that
/// column has loaded, which is why the tile can appear a moment after the
/// other four.
let documentCount: Int?

private struct Tile: Identifiable {
let id: String
let label: String
let value: Int
}

private var tiles: [Tile] {
var result: [Tile] = []
if let followers = counts?.followers ?? profile.followerCount {
result.append(Tile(id: "followers", label: "Followers", value: followers))
}
if let following = counts?.following ?? profile.followingCount {
result.append(Tile(id: "following", label: "Following", value: following))
}
if let posts = profile.publicMessageCount {
result.append(Tile(id: "posts", label: "Posts", value: posts))
}
if let documentCount {
result.append(Tile(id: "documents", label: "Documents", value: documentCount))
}
if let lists = profile.publicListCount {
result.append(Tile(id: "lists", label: "Lists", value: lists))
}
return result
}

var body: some View {
if !tiles.isEmpty {
HStack(alignment: .top, spacing: 12) {
ForEach(tiles) { tile in
VStack(spacing: 2) {
Text(tile.value.formatted())
.font(.ilTitle(18))
.monospacedDigit()
Text(tile.label)
.font(.ilMono(10))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(ILColor.surface2, in: RoundedRectangle(cornerRadius: ILMetric.radiusMd))
.accessibilityElement(children: .combine)
.accessibilityLabel("\(tile.value) \(tile.label)")
}
}
}
}
}
78 changes: 76 additions & 2 deletions App/Features/Social/ProfileViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ final class ProfileViewModel {
init(
social: SocialServicing,
relationshipReader: FollowRelationshipReading,
currentUserID: @MainActor @escaping () -> String?
currentUserID: @MainActor @escaping () -> String?,
currentUsername: @MainActor @escaping () -> String? = { nil }
) {
self.social = social
self.relationshipReader = relationshipReader
self.currentUserIDProvider = currentUserID
self.currentUsernameProvider = currentUsername
}

/// Reads the signed-in user's id when configuring the follow
Expand All @@ -102,6 +104,41 @@ final class ProfileViewModel {
/// out while the profile view is open).
private let currentUserIDProvider: @MainActor () -> String?

/// Reads the signed-in user's handle, for the self-profile landing.
/// A closure for the same reason as `currentUserIDProvider`.
private let currentUsernameProvider: @MainActor () -> String?

/// Normalises a handle the way the site's URLs do (GitHub #44).
///
/// Handles are **case-insensitive** (`/user/adron` == `/user/Adron`) and
/// `/@username` is a documented shortcut, so a deep link or a typed handle
/// in either form has to resolve. Usernames are `[A-Za-z0-9_.-]`; anything
/// else typed at signup becomes `_` in the URL while the display name keeps
/// what was typed — so stripping a leading `@` and lowercasing is the whole
/// job, and the rest of the string is passed through untouched rather than
/// sanitised against a charset the client would only get subtly wrong.
static func normalizedHandle(_ raw: String) -> String {
var trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.hasPrefix("@") { trimmed.removeFirst() }
return trimmed.lowercased()
}

/// True when the loaded profile is the signed-in user's own.
///
/// Compared on **id**, not handle: the id is what the session and the
/// profile payload agree on, and a handle comparison would go wrong exactly
/// where this matters — on a rename.
var isOwnProfile: Bool {
guard let profile, let current = currentUserIDProvider() else { return false }
return profile.id == current
}

/// True when a session exists. The Watch affordance is offered only then.
var isSignedIn: Bool { currentUserIDProvider() != nil }

/// The signed-in user's handle, if any.
var ownUsername: String? { currentUsernameProvider() }

/// The relationship reader handed through to the follow button.
private let relationshipReader: FollowRelationshipReading

Expand All @@ -116,7 +153,10 @@ final class ProfileViewModel {
/// Counts failure is logged and dropped — the profile header is the
/// load-bearing data and stays rendered.
func loadProfile(username: String) async {
let trimmed = username.trimmingCharacters(in: .whitespacesAndNewlines)
// Normalised here, at the single entry point, so a deep link, a typed
// handle and the self-profile landing all agree on what "adron",
// "@Adron" and "/user/ADRON" resolve to (GitHub #44).
let trimmed = Self.normalizedHandle(username)
guard !trimmed.isEmpty else { return }

loadedUsername = trimmed
Expand All @@ -127,6 +167,10 @@ final class ProfileViewModel {
isLoading = true
defer { isLoading = false }

// Whether this load is for the signed-in user, decided *before* the
// request so the failure path can use it too.
let isSelf = currentUsernameProvider().map { Self.normalizedHandle($0) == trimmed } ?? false

do {
let resolved = try await social.profile(username: trimmed)
profile = resolved
Expand Down Expand Up @@ -168,11 +212,41 @@ final class ProfileViewModel {
targetUserID: resolved.id,
currentUserID: currentUserIDProvider()
)
} catch let socialError as SocialError {
// Decision 0002 / GitHub #44. `profileUnavailable` means "this user
// has no public messages, so there is nothing to project a profile
// from" — a statement about *other* people's public content. It must
// never be shown for your own account: a new user with nothing
// posted yet would open Profile and be told their profile does not
// exist.
//
// In practice the rich endpoint answers for the signed-in user
// anyway, so this is a guard against the fallback path, not an
// everyday branch. It is still worth holding, because the failure it
// prevents is the worst first impression the app can make.
if case .profileUnavailable = socialError, isSelf {
self.error = nil
} else {
self.error = socialError
}
} catch {
self.error = error
}
}

/// Opens the signed-in user's own profile.
///
/// The reason this issue exists: `ProfileRootView` landed on an empty
/// *"enter a username"* prompt even though the session already knew who the
/// user was, so the one profile everybody wants to see took the most typing
/// to reach. No-op while signed out, and no-op if a profile is already
/// loaded — re-entering the tab should not yank the user off whoever they
/// were browsing.
func loadOwnProfileIfNeeded() async {
guard loadedUsername == nil, let handle = currentUsernameProvider() else { return }
await loadProfile(username: handle)
}

/// Re-runs `loadProfile` for the currently loaded username. Bound to
/// the "Try again" button on the error state. No-op when no username
/// is loaded yet.
Expand Down
Loading