78ba61034f
- Remove .toolbarBackground(.visible) from all NavigationStack views: this modifier suppresses large title rendering in iOS 26. - Discover: switch to always-present List + overlay pattern so SwiftUI never loses the scroll context during loading transitions. - Discover, Notifications, Groups: use .inline title mode; the tab bar already identifies these screens, large titles are redundant. - RootView: add .toolbarColorScheme propagation for nav bar foreground. - Settings sheet: apply theme background and toolbar color to the Form.
392 lines
18 KiB
Swift
392 lines
18 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 blockList = BlockList.shared
|
|
@State private var searchQuery: String = ""
|
|
|
|
@Environment(\.appTheme) private var theme
|
|
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))
|
|
}
|
|
HStack(spacing: 12) {
|
|
Text("Following \(profile.follows.count)")
|
|
if let followers = viewModel.followerCount {
|
|
Text("\(followers) followers")
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
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)
|
|
}
|
|
if !isOwnProfile {
|
|
Button(role: blockList.isBlocked(feedURL) ? nil : .destructive) {
|
|
if blockList.isBlocked(feedURL) {
|
|
blockList.unblock(feedURL)
|
|
} else {
|
|
blockList.block(feedURL)
|
|
}
|
|
} label: {
|
|
Label(blockList.isBlocked(feedURL) ? "Unblock" : "Block",
|
|
systemImage: blockList.isBlocked(feedURL) ? "hand.raised.slash" : "hand.raised")
|
|
.font(.caption.weight(.medium))
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 8)
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
|
|
// 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)
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
if let birthday = profile.birthday {
|
|
metaRow(icon: "gift", text: birthday)
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
if !profile.languages.isEmpty {
|
|
metaRow(icon: "globe", text: profile.languages.joined(separator: ", "))
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
ForEach(profile.links, id: \.absoluteString) { link in
|
|
Link(destination: link) {
|
|
metaRow(icon: "link", text: link.absoluteString, tinted: true, wraps: true)
|
|
}
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
ForEach(profile.contacts, id: \.self) { contact in
|
|
metaRow(icon: "envelope", text: contact, wraps: true)
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
.listRowBackground(theme.secondaryBackground)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 mutedTokens = MuteFilter.tokens(from: UserDefaults.standard.string(forKey: "mutedWords") ?? "")
|
|
let displayPosts = profile.posts
|
|
.filter { !$0.isPureInteraction }
|
|
.filter { !MuteFilter.shouldHide($0, mutedTokens: mutedTokens) }
|
|
.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(theme.background)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.scrollContentBackground(.hidden)
|
|
.background(theme.background)
|
|
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
|
|
.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)
|
|
}
|
|
}
|
|
}
|