Files
andros bc29cda05a Gate relay-only features, add pin/migrate/test-connection actions, fix profile refresh
Relay=false degradation:
- Notifications, Discover, Groups and Thread ViewModels now bail with a "X requires a relay. Enable Use Relay in Settings." message instead of firing requests that would fail.
- PostRowView.fetchInteractionData skips the /replies/ and /interactions/ calls when the relay is disabled so timeline rows stop emitting background noise.
- SearchView.onSubmit also respects useRelay. TimelineView already fell back to the follow list via TimelineFetcher.

Profile edit UX:
- ProfileView reloads via a new ProfileViewModel.reload() (bypassCache=true) on the Edit Profile sheet's onDismiss, so header/description edits show up instantly instead of waiting for a tab switch.
- EditProfileViewModel now also bypassCache on both load and save, so the form reflects the authoritative feed and save patches fresh content rather than a stale CDN copy.
- ProfileFetcher.fetch gains a bypassCache parameter forwarded to FeedFetcher.

Pin / Unpin own post:
- Context menu on an own post exposes "Pin to profile" and "Unpin" actions.
- PostActionsViewModel.pinPost/unpinPost rewrite the feed's #+PINNED keyword through ProfileWriter.setKeyword/removeKeyword and upload.

Migration announcement:
- New Account section in Settings with "Announce account migration" button.
- MigrationSheet lets the user enter the new feed URL and posts a :MIGRATION: <old> <new> entry through NewPostOptions.migration and PostWriter.appendPost.

Settings test-connection:
- Each upload method section (VFile / GitHub / Codeberg / WebDAV) shows a "Test connection" button that GETs the Public Feed URL with cache bypassed and parses it. Shows the parsed nick on success; inline error on failure. This catches the common misconfiguration of pasting a wrong URL without exercising a destructive upload.

Miscellaneous:
- Thread and Relay clients get a 10s request timeout so slow or unreachable hosts can't pin the UI on spinner for the 60s URLSession default.
- TimelineView switched to ScrollView + LazyVStack (from List) to avoid SwiftUI List rows auto-firing the first NavigationLink on any tap — the underlying cause of "back from a thread lands on a profile" reports.
- Removed the opacity(0.7) on thread ancestors so they no longer look washed out.
- PostRowView action-bar buttons gained explicit accessibilityLabel ("Reply", "React", "Boost", "Boosted") in place of raw SF Symbol names.
- PostRowView body text is now a NavigationLink into the thread when the post has replies or is itself a reply.
- TODO.org captures the full QA round.
2026-04-20 21:59:52 +02:00

107 lines
4.1 KiB
Swift

import SwiftUI
import OrgSocialKit
struct SearchView: View {
@State private var viewModel = SearchViewModel()
@AppStorage("useRelay") private var useRelay = true
var body: some View {
NavigationStack {
VStack(spacing: 0) {
searchBar
Divider()
content
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.large)
.navigationDestination(for: URL.self) { ProfileView(feedURL: $0) }
.navigationDestination(for: ThreadRoute.self) {
ThreadView(postURL: $0.postURL, relayURL: $0.relayURL)
}
}
}
private var searchBar: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField(viewModel.isTagSearch ? "Tag name…" : "Search posts…", text: $viewModel.query)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.submitLabel(.search)
.onSubmit { if useRelay { Task { await viewModel.search() } } }
if !viewModel.query.isEmpty {
Button { viewModel.query = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
.padding(8)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
Button(viewModel.isTagSearch ? "#Tag" : "Text") {
viewModel.isTagSearch.toggle()
}
.font(.caption.weight(.semibold))
.padding(.horizontal, 10).padding(.vertical, 8)
.background(Color.accentColor.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
.foregroundStyle(Color.accentColor)
}
Button {
Task { await viewModel.search() }
} label: {
Label("Search", systemImage: "magnifyingglass")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.query.trimmingCharacters(in: .whitespaces).isEmpty || viewModel.isLoading || !useRelay)
if !useRelay {
Text("Enable Use Relay in Settings to search.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding()
}
@ViewBuilder
private var content: some View {
if viewModel.isLoading {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.errorMessage {
ContentUnavailableView {
Label("Search failed", systemImage: "exclamationmark.triangle")
} description: {
Text(error)
}
} else if viewModel.results.isEmpty && !viewModel.query.isEmpty {
ContentUnavailableView(
"No results",
systemImage: "magnifyingglass",
description: Text("No posts found for \"\(viewModel.query)\".")
)
} else if viewModel.results.isEmpty {
ContentUnavailableView(
"Search posts",
systemImage: "magnifyingglass",
description: Text("Enter a keyword or tag to search posts on the relay.")
)
} else {
List {
ForEach(viewModel.results, 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(Color.clear)
}
}
.listStyle(.plain)
}
}
}