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.
179 lines
7.1 KiB
Swift
179 lines
7.1 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
struct GroupsView: View {
|
|
@State private var viewModel = GroupsViewModel()
|
|
@Environment(\.appTheme) private var theme
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
Group {
|
|
if viewModel.isLoadingGroups {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.groupsError {
|
|
ContentUnavailableView {
|
|
Label("Groups unavailable", systemImage: "person.3.slash")
|
|
} description: {
|
|
Text(error)
|
|
} actions: {
|
|
Button("Retry") { Task { await viewModel.loadGroups() } }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
} else if viewModel.items.isEmpty {
|
|
ContentUnavailableView(
|
|
"No groups",
|
|
systemImage: "person.3",
|
|
description: Text("No groups are registered on this relay.")
|
|
)
|
|
} else {
|
|
groupList
|
|
}
|
|
}
|
|
.background(theme.background)
|
|
.navigationTitle("Groups")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task { await viewModel.loadGroups() }
|
|
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
|
|
// Safe navigation: no NavigationLink inside List rows.
|
|
// selectedItem drives the destination to avoid the iOS bug where
|
|
// tapping a Button in a row fires the nearest NavigationLink instead.
|
|
.navigationDestination(item: $viewModel.selectedItem) { item in
|
|
GroupPostsView(item: item, viewModel: viewModel)
|
|
}
|
|
.alert("Error", isPresented: .constant(viewModel.followError != nil)) {
|
|
Button("OK") { viewModel.followError = nil }
|
|
} message: {
|
|
Text(viewModel.followError ?? "")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var groupList: some View {
|
|
List(viewModel.items) { item in
|
|
HStack(spacing: 12) {
|
|
// Tap area: navigates to group posts
|
|
Button {
|
|
viewModel.selectedItem = item
|
|
} label: {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: item.slug == nil ? "person.3.slash" : "person.3.fill")
|
|
.foregroundStyle(item.slug == nil ? .secondary : .primary)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(item.name)
|
|
.foregroundStyle(.primary)
|
|
if item.slug == nil {
|
|
Text("Not on current relay")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if viewModel.canFollow {
|
|
followButton(for: item)
|
|
}
|
|
}
|
|
.listRowSeparator(.visible)
|
|
.listRowSeparatorTint(Color.secondary.opacity(0.2))
|
|
.listRowBackground(theme.background)
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(theme.background)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func followButton(for item: GroupDisplayItem) -> some View {
|
|
let toggling = viewModel.isToggling(item)
|
|
Button {
|
|
Task { await viewModel.toggleFollow(item) }
|
|
} label: {
|
|
if toggling {
|
|
ProgressView()
|
|
.frame(width: 70, height: 28)
|
|
} else {
|
|
Text(item.isFollowed ? "Unfollow" : "Follow")
|
|
.font(.subheadline)
|
|
.fontWeight(item.isFollowed ? .regular : .semibold)
|
|
.frame(width: 72)
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(item.isFollowed ? .secondary : .accentColor)
|
|
.disabled(toggling)
|
|
}
|
|
}
|
|
|
|
// Alias so the navigation destination closure can refer to the type directly.
|
|
private typealias GroupDisplayItem = GroupsViewModel.GroupDisplayItem
|
|
|
|
private struct GroupPostsView: View {
|
|
let item: GroupDisplayItem
|
|
var viewModel: GroupsViewModel
|
|
@State private var showCompose = false
|
|
@Environment(\.appTheme) private var theme
|
|
|
|
var body: some View {
|
|
Group {
|
|
if viewModel.isLoadingPosts {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.postsError {
|
|
ContentUnavailableView {
|
|
Label("Could not load posts", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(error)
|
|
} actions: {
|
|
Button("Retry") { Task { await viewModel.loadPosts(for: item) } }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
} else if viewModel.posts.isEmpty {
|
|
ContentUnavailableView(
|
|
"No posts",
|
|
systemImage: "bubble.left.and.bubble.right",
|
|
description: Text("This group has no posts yet.")
|
|
)
|
|
} else {
|
|
List {
|
|
ForEach(viewModel.posts, id: \.timestamp) { post in
|
|
PostRowView(post: post)
|
|
.listRowSeparator(.visible)
|
|
.listRowSeparatorTint(Color.secondary.opacity(0.2))
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
.listRowBackground(theme.background)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.background(theme.background)
|
|
.navigationDestination(for: URL.self) { ProfileView(feedURL: $0) }
|
|
.navigationDestination(for: ThreadRoute.self) {
|
|
ThreadView(postURL: $0.postURL, relayURL: $0.relayURL)
|
|
}
|
|
}
|
|
}
|
|
.background(theme.background)
|
|
.navigationTitle(item.name)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button { showCompose = true } label: {
|
|
Image(systemName: "square.and.pencil")
|
|
}
|
|
.accessibilityLabel("Compose")
|
|
}
|
|
}
|
|
.sheet(isPresented: $showCompose) {
|
|
let relayBase = UserDefaults.standard.string(forKey: "relayURL") ?? "https://relay.org-social.org"
|
|
ComposeView(group: "\(item.name) \(relayBase)")
|
|
}
|
|
.task { await viewModel.loadPosts(for: item) }
|
|
}
|
|
}
|