17eb09cb99
When the feed URL has the shape /<nick>/social.org we expose a "View blog" button under "View social.org" / "Share RSS" pointing at <previewServiceURL>/blog/<nick>. The preview service renders the feed as an HTML blog for vfile-style hosts. Detection is path-based, so the button appears for the user's own profile when uploadMethod == vfile and also for any followed profile served from a vfile host (the URL pattern is observable). For raw Git or arbitrary HTTP hosts the path doesn't end in social.org with exactly one nick segment in front, so the button stays hidden. Layout: the three header buttons (View social.org, Share RSS, View blog) wrap to a second row when blog applies, so the row stays readable on smaller phones.
358 lines
16 KiB
Swift
358 lines
16 KiB
Swift
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 `/<nick>/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)
|
|
}
|
|
}
|
|
}
|