import SwiftUI import OrgSocialKit struct ProfileView: View { @Environment(\.openURL) private var openURL @State private var viewModel: ProfileViewModel @State private var showEditProfile = false @State private var isTogglingFollow = false @State private var followCoordinator = FollowCoordinator.shared @State private var searchQuery: String = "" private let feedURL: URL private var isFollowing: Bool { followCoordinator.isFollowing(feedURL) } private var isOwnProfile: Bool { guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"), let own = URL(string: raw) else { return false } return FollowCoordinator.normalize(own) == FollowCoordinator.normalize(feedURL) } init(feedURL: URL) { self.feedURL = feedURL _viewModel = State(initialValue: ProfileViewModel(feedURL: feedURL)) } var body: some View { Group { if let profile = viewModel.profile { profileContent(profile) } else if let error = viewModel.errorMessage { ContentUnavailableView { Label("Profile unavailable", systemImage: "person.slash") } description: { Text(error) } actions: { Button("Retry") { Task { await viewModel.load() } } .buttonStyle(.bordered) } } else { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } } .navigationBarTitleDisplayMode(.inline) .toolbar { profileToolbar } .sheet(isPresented: $showEditProfile, onDismiss: { // Refresh profile so edits appear immediately. Task { await viewModel.reload() } }) { EditProfileView() } .task { // For the user's own profile bypass CDN cache so freshly-added // #+FOLLOW: entries and header edits show up immediately. if isOwnProfile { await viewModel.reload() } else { await viewModel.load() } await followCoordinator.refreshIfNeeded() } // Pull-to-refresh: always bypass the CDN so the user can force // a fresh read (e.g. after composing from another device). .refreshable { await viewModel.reload() await followCoordinator.forceRefresh() } // Search inside the profile's posts. Case-insensitive substring // match on body + tags. Keeps profile browsing usable for feeds // that have accumulated hundreds of posts. .searchable(text: $searchQuery, placement: .navigationBarDrawer(displayMode: .automatic), prompt: "Search posts") .onChange(of: followCoordinator.feedVersion) { _, _ in // Own feed changed elsewhere (compose, edit, delete, follow toggle, profile edit). // SwiftUI's TabView preserves state so .task doesn't fire again; this hook does. if isOwnProfile { Task { await viewModel.reload() } } } } @ViewBuilder private func profileContent(_ profile: OrgSocialProfile) -> some View { List { // MARK: Header section Section { VStack(spacing: 16) { AvatarView(url: profile.avatar, nick: profile.nick, size: 80) VStack(spacing: 4) { if let nick = profile.nick { Text("@\(nick)") .font(.title2.weight(.bold)) } if let title = profile.title { Text(title) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } if let description = profile.description { Text(description) .font(.body) .multilineTextAlignment(.center) .foregroundStyle(.primary) } // Two-row HStack-of-HStacks: keeps the three small // buttons readable on iPhone SE-sized screens where // a single row would overflow when "View blog" is // present (vfile-hosted feeds only). VStack(spacing: 8) { HStack(spacing: 8) { Button { openURL(feedURL) } label: { Label("View social.org", systemImage: "safari") .font(.caption.weight(.medium)) } .buttonStyle(.bordered) .controlSize(.small) if let rssURL = rssFeedURL { ShareLink(item: rssURL) { Label("Share RSS", systemImage: "dot.radiowaves.up.forward") .font(.caption.weight(.medium)) } .buttonStyle(.bordered) .controlSize(.small) } } if let blogURL = vfileBlogURL { Button { openURL(blogURL) } label: { Label("View blog", systemImage: "doc.richtext") .font(.caption.weight(.medium)) } .buttonStyle(.bordered) .controlSize(.small) } } } .frame(maxWidth: .infinity) .padding(.vertical, 8) } // MARK: Meta section let hasMeta = profile.location != nil || profile.birthday != nil || !profile.languages.isEmpty || !profile.links.isEmpty || !profile.contacts.isEmpty if hasMeta { Section("Info") { if let location = profile.location { metaRow(icon: "mappin", text: location) } if let birthday = profile.birthday { metaRow(icon: "gift", text: birthday) } if !profile.languages.isEmpty { metaRow(icon: "globe", text: profile.languages.joined(separator: ", ")) } ForEach(profile.links, id: \.absoluteString) { link in Link(destination: link) { // Show the full URL (scheme + host + path) so the // user can tell apart entries that share a host // (e.g. https://andros.dev vs gemini://andros.dev) // and see paths instead of just the host. metaRow(icon: "link", text: link.absoluteString, tinted: true, wraps: true) } } ForEach(profile.contacts, id: \.self) { contact in metaRow(icon: "envelope", text: contact, wraps: true) } } } // MARK: Follows section if !profile.follows.isEmpty { Section("Following (\(profile.follows.count))") { ForEach(profile.follows, id: \.url) { follow in let details = viewModel.enrichedFollows[follow.url] let displayNick = follow.name ?? details?.nick NavigationLink(value: follow.url) { HStack(spacing: 10) { AvatarView(url: details?.avatar, nick: displayNick, size: 32) VStack(alignment: .leading, spacing: 2) { if let displayNick { Text(displayNick) .font(.subheadline.weight(.semibold)) } Text(follow.url.host ?? follow.url.absoluteString) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } } } } } // MARK: Groups section if !profile.groups.isEmpty { Section("Groups (\(profile.groups.count))") { ForEach(profile.groups, id: \.name) { group in HStack(spacing: 10) { Image(systemName: "person.3.fill") .foregroundStyle(.secondary) .frame(width: 20) VStack(alignment: .leading, spacing: 2) { Text(group.name).font(.subheadline.weight(.semibold)) Text(group.relayURL.host ?? group.relayURL.absoluteString) .font(.caption).foregroundStyle(.secondary) } } } } } // MARK: Pinned post if let pinnedTimestamp = profile.pinned, let pinned = profile.posts.first(where: { $0.timestamp == pinnedTimestamp }) { Section { PostRowView(post: enriched(pinned, profile: profile), onDelete: { viewModel.removePost(timestamp: pinned.timestamp) }, onEdit: { result in viewModel.applyEdit(timestamp: pinned.timestamp, result: result) }) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) .listRowBackground(Color.accentColor.opacity(0.04)) } header: { Label("Pinned", systemImage: "pin.fill").font(.caption.weight(.semibold)) } } // MARK: Posts section // Pure reactions / boosts / votes are protocol artefacts and are // filtered out the same way as on the timeline — the profile // shows the user's authored content, not a log of their reactions. let displayPosts = profile.posts .filter { !$0.isPureInteraction } .filter { matchesSearch($0, query: searchQuery) } if !displayPosts.isEmpty { Section("Posts (\(displayPosts.count))") { ForEach(displayPosts.reversed(), id: \.timestamp) { post in PostRowView(post: enriched(post, profile: profile), onDelete: { viewModel.removePost(timestamp: post.timestamp) }, onEdit: { result in viewModel.applyEdit(timestamp: post.timestamp, result: result) }) .listRowSeparator(.visible) .listRowSeparatorTint(Color.secondary.opacity(0.2)) .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) .listRowBackground(Color.clear) } } } } .listStyle(.insetGrouped) .navigationDestination(for: URL.self) { ProfileView(feedURL: $0) } .navigationDestination(for: ThreadRoute.self) { ThreadView(postURL: $0.postURL, relayURL: $0.relayURL) } } /// Builds the preview service's blog URL when the feed looks like a /// vfile-hosted feed (path shape `//social.org`). For feeds /// served from raw Git or arbitrary HTTP locations the path doesn't /// match, so the button is hidden. The base comes from the configured /// preview service URL (Settings -> Sharing) so a user pointing at a /// self-hosted preview instance gets the same affordance. private var vfileBlogURL: URL? { let segments = feedURL.path.split(separator: "/").map(String.init) guard segments.count >= 2, segments.last?.lowercased() == "social.org" else { return nil } let nick = segments[segments.count - 2] guard !nick.isEmpty else { return nil } let raw = (UserDefaults.standard.string(forKey: "previewServiceURL") ?? "https://preview.org-social.org/") .trimmingCharacters(in: .whitespaces) let base = raw.hasSuffix("/") ? String(raw.dropLast()) : raw return URL(string: "\(base)/blog/\(nick)") } /// Builds the relay's per-feed RSS URL from the configured relay base. /// Falls back to nil when no relay is configured so the Share button /// stays hidden (the lib also exposes `/rss.xml` only on the relay). private var rssFeedURL: URL? { let raw = UserDefaults.standard.string(forKey: "relayURL") ?? "" guard let relay = URL(string: raw) else { return nil } let base = relay.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "+") let encoded = feedURL.absoluteString.addingPercentEncoding(withAllowedCharacters: allowed) ?? feedURL.absoluteString return URL(string: "\(base)/rss.xml?feed=\(encoded)") } private func matchesSearch(_ post: OrgSocialPost, query: String) -> Bool { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return true } let needle = trimmed.lowercased() if post.text.lowercased().contains(needle) { return true } if post.tags.contains(where: { $0.lowercased().contains(needle) }) { return true } return false } private func enriched(_ post: OrgSocialPost, profile: OrgSocialProfile) -> OrgSocialPost { var p = post p.authorNick = profile.nick p.authorURL = profile.feedURL p.authorAvatar = profile.avatar p.feedURL = profile.feedURL return p } @ToolbarContentBuilder private var profileToolbar: some ToolbarContent { ToolbarItemGroup(placement: .navigationBarTrailing) { if isOwnProfile { Button { showEditProfile = true } label: { Image(systemName: "pencil") } } else { if isTogglingFollow { ProgressView() } else { Button { Task { await toggleFollow() } } label: { Text(isFollowing ? "Unfollow" : "Follow") .fontWeight(isFollowing ? .regular : .semibold) } } } } } private func toggleFollow() async { isTogglingFollow = true defer { isTogglingFollow = false } let nick = viewModel.profile?.nick await followCoordinator.toggle(url: feedURL, nick: nick) } private func metaRow(icon: String, text: String, tinted: Bool = false, wraps: Bool = false) -> some View { HStack(alignment: wraps ? .top : .center, spacing: 10) { Image(systemName: icon) .foregroundStyle(.secondary) .frame(width: 20) Text(text) .foregroundStyle(tinted ? Color.accentColor : Color.primary) .lineLimit(wraps ? nil : 1) .fixedSize(horizontal: false, vertical: wraps) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) } } }