Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- 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.
2026-05-24 21:10:04 +02:00

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)
}
}
}