commit bc2bb9145e26626e81c43289441e9b28ff020eda
parent f476611d115f0c9030a8c45ca9da0a15efb6da0f
Author: triesap <tyson@radroots.org>
Date: Sun, 15 Feb 2026 16:23:04 +0000
app: add market trade listing flows and rhi config
Diffstat:
14 files changed, 1216 insertions(+), 172 deletions(-)
diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj
@@ -12,8 +12,11 @@
275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71A93F98C7B93188748B99B /* ProfileView.swift */; };
2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; };
2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; };
+ 33A800AA701C354099623B24 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08C0B870E44C7B152A7FABE0 /* MarketView.swift */; };
360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; };
4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; };
+ 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */; };
+ 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */; };
5AECD474FB2F91855BDD79C0 /* PostFeedViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21554DA87EEC1E5C5F38365 /* PostFeedViewModel.swift */; };
7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; };
8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; };
@@ -30,25 +33,28 @@
EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */; };
F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */; };
F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; };
+ FD9B01F8F5DD1F05A64FD556 /* TradeOrderRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
+ 08C0B870E44C7B152A7FABE0 /* MarketView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketView.swift; sourceTree = "<group>"; };
08FA88664E5E3ED3A24D56CC /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
0A0274A0260D1C04F40C71AF /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
138AA7BAA021EE13E829390B /* Bundle+Build.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Build.swift"; sourceTree = "<group>"; };
16A7641E5C643B4B36CFEDA8 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
227028B4EBDC6703999FB9DA /* ToastModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastModifier.swift; sourceTree = "<group>"; };
+ 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingDetailView.swift; sourceTree = "<group>"; };
2818363B157125491FB84A1E /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = "<group>"; };
2FE790CA1CD31208947913B9 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
3FADD6E2563CC9AF9F935DCE /* RadrootsKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = RadrootsKit; path = RadrootsKit; sourceTree = SOURCE_ROOT; };
41A4289F43625DD65E6C4B25 /* CopyRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyRow.swift; sourceTree = "<group>"; };
+ 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeOrderRequestView.swift; sourceTree = "<group>"; };
4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.xcconfig; sourceTree = "<group>"; };
- 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.git.xcconfig; sourceTree = "<group>"; };
676B89EB116B60AE8C2B4313 /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = "<group>"; };
- 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = radroots.local.xcconfig; sourceTree = "<group>"; };
7C294E8EF50F5E1E73F5C135 /* Common.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Common.xcconfig; sourceTree = "<group>"; };
93AA285819DD1269C3EAD80A /* Radroots.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Radroots.app; sourceTree = BUILT_PRODUCTS_DIR; };
93D729E070C32490545FA837 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
+ 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TradeListingCreateView.swift; sourceTree = "<group>"; };
9AE0EB327C10171444553378 /* PostFeedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostFeedView.swift; sourceTree = "<group>"; };
A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Nav.swift"; sourceTree = "<group>"; };
ADE61264E2C98E73828E8680 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
@@ -101,8 +107,6 @@
5FD6379AE27C57D02E8C7EE1 /* Radroots */ = {
isa = PBXGroup;
children = (
- 54EE5A34FE2086899F5B7568 /* radroots.git.xcconfig */,
- 7BCA99336E305EC789152DDE /* radroots.local.xcconfig */,
4BC4B7D0BB4C6D8E4B0AA4AD /* radroots.xcconfig */,
23C2D7FF63B61CD356979E82 /* App */,
579F407D96CCAFD4000EF363 /* Config */,
@@ -162,6 +166,7 @@
isa = PBXGroup;
children = (
0A0274A0260D1C04F40C71AF /* HomeView.swift */,
+ 08C0B870E44C7B152A7FABE0 /* MarketView.swift */,
CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */,
F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */,
9AE0EB327C10171444553378 /* PostFeedView.swift */,
@@ -170,6 +175,9 @@
C1D9496F9F05A4E79E73A247 /* RelaysView.swift */,
E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */,
16A7641E5C643B4B36CFEDA8 /* SetupView.swift */,
+ 947ADA6D32E42ED2B40A5351 /* TradeListingCreateView.swift */,
+ 26BAE32CD1D46033DDA1A5BB /* TradeListingDetailView.swift */,
+ 466BFA2F60BE3113EDD1BA3B /* TradeOrderRequestView.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -306,6 +314,7 @@
4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */,
1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */,
C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */,
+ 33A800AA701C354099623B24 /* MarketView.swift in Sources */,
F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */,
EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */,
8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */,
@@ -317,6 +326,9 @@
2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */,
7FD8FB018DA09568303194B2 /* Strings.swift in Sources */,
D62E9461833A0AA5E622A1E6 /* ToastModifier.swift in Sources */,
+ 505A5731ACDBBB0296134340 /* TradeListingCreateView.swift in Sources */,
+ 4B44B723FF06ECC363A486BA /* TradeListingDetailView.swift in Sources */,
+ FD9B01F8F5DD1F05A64FD556 /* TradeOrderRequestView.swift in Sources */,
B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/Radroots/Views/HomeView.swift b/Radroots/Views/HomeView.swift
@@ -1,21 +1,27 @@
import SwiftUI
-import RadrootsKit
private enum HomeTab: Hashable {
- case home
+ case feed
+ case market
case settings
}
struct HomeView: View {
- @State private var selection: HomeTab = .home
+ @State private var selection: HomeTab = .feed
var body: some View {
TabView(selection: $selection) {
NavigationStack {
- HomeDashboardView()
+ PostFeedView()
}
- .tabItem { Label("Home", systemImage: "house.fill") }
- .tag(HomeTab.home)
+ .tabItem { Label("Feed", systemImage: "text.bubble.fill") }
+ .tag(HomeTab.feed)
+
+ NavigationStack {
+ MarketView()
+ }
+ .tabItem { Label("Market", systemImage: "leaf") }
+ .tag(HomeTab.market)
NavigationStack {
SettingsView()
@@ -25,61 +31,3 @@ struct HomeView: View {
}
}
}
-
-private struct HomeDashboardView: View {
- @EnvironmentObject private var app: AppState
-
- var body: some View {
- List {
- Section("Your Identity") {
- NavigationLink {
- ProfileView()
- } label: {
- HStack {
- Text("Profile")
- Spacer()
- Text(app.npub ?? "—")
- .font(.callout.monospaced())
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
- }
- }
- }
-
- Section("Relays") {
- NavigationLink {
- RelaysView()
- } label: {
- HStack {
- Text("Relays")
- Spacer()
- if app.relayConnectedCount > 0 {
- Label("\(app.relayConnectedCount)", systemImage: "dot.radiowaves.left.and.right")
- .labelStyle(.titleAndIcon)
- .foregroundStyle(.secondary)
- }
- }
- }
- }
-
- Section("Compose") {
- NavigationLink {
- PostCreateView()
- } label: {
- Label("New Post", systemImage: "square.and.pencil")
- }
- }
-
- Section("Explore") {
- NavigationLink {
- PostFeedView()
- } label: {
- Label("Public Feed", systemImage: "text.bubble.fill")
- }
- }
- }
- .listStyle(.insetGrouped)
- .inlineNavigationTitle("Home")
- }
-}
diff --git a/Radroots/Views/MarketView.swift b/Radroots/Views/MarketView.swift
@@ -0,0 +1,189 @@
+import SwiftUI
+import RadrootsKit
+
+@MainActor
+final class TradeListingsViewModel: ObservableObject {
+ @Published var listings: [TradeListingSummary] = []
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+ @Published var searchText: String = ""
+
+ func loadIfNeeded(app: AppState) async {
+ if listings.isEmpty {
+ await refresh(app: app)
+ }
+ }
+
+ func refresh(app: AppState) async {
+ guard let rt = app.radroots.runtime else { return }
+ isLoading = true
+ errorMessage = nil
+
+ let result: Result<[TradeListingSummary], Error> = await Task.detached { @Sendable in
+ do {
+ return .success(try rt.tradeListingsFetch(limit: 60, sinceUnix: nil))
+ } catch {
+ return .failure(error)
+ }
+ }.value
+
+ switch result {
+ case .success(let items):
+ listings = items
+ isLoading = false
+ case .failure(let error):
+ errorMessage = String(describing: error)
+ isLoading = false
+ }
+ }
+}
+
+struct MarketView: View {
+ @EnvironmentObject private var app: AppState
+ @StateObject private var vm = TradeListingsViewModel()
+ @State private var showCreate = false
+
+ var body: some View {
+ List {
+ if let error = vm.errorMessage {
+ Section {
+ Text(error)
+ .foregroundStyle(.red)
+ .font(.footnote)
+ }
+ }
+
+ if filteredListings.isEmpty {
+ if vm.isLoading {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .listRowBackground(Color.clear)
+ } else {
+ ContentUnavailableView(
+ "No Listings Yet",
+ systemImage: "leaf",
+ description: Text("Connect to relays and pull listings from the network.")
+ )
+ .listRowBackground(Color.clear)
+ }
+ } else {
+ Section {
+ ForEach(filteredListings) { listing in
+ NavigationLink {
+ TradeListingDetailView(listing: listing)
+ } label: {
+ TradeListingRow(listing: listing)
+ }
+ }
+ }
+ }
+ }
+ .listStyle(.plain)
+ .navigationTitle("Market")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if vm.isLoading {
+ ProgressView()
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreate = true
+ } label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ .searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
+ .task { await vm.loadIfNeeded(app: app) }
+ .refreshable { await vm.refresh(app: app) }
+ .sheet(isPresented: $showCreate) {
+ NavigationStack {
+ TradeListingCreateView {
+ Task { await vm.refresh(app: app) }
+ }
+ }
+ }
+ }
+
+ private var filteredListings: [TradeListingSummary] {
+ let trimmed = vm.searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return vm.listings }
+ let needle = trimmed.lowercased()
+ return vm.listings.filter { listing in
+ let haystack = [
+ listing.title,
+ listing.description,
+ listing.productType,
+ listing.location,
+ listing.sellerPubkey
+ ]
+ .joined(separator: " ")
+ .lowercased()
+ return haystack.contains(needle)
+ }
+ }
+}
+
+private struct TradeListingRow: View {
+ let listing: TradeListingSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(listing.title)
+ .font(.headline)
+ .foregroundStyle(.primary)
+ Spacer()
+ Text(listing.availability.capitalized)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+
+ if !listing.description.isEmpty {
+ Text(listing.description)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ HStack {
+ Text(priceLine)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ Text("Inventory \(listing.inventoryAvailable)")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Text(binLine)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 12) {
+ Label(listing.deliveryMethod.capitalized, systemImage: "truck.box")
+ Label(listing.location, systemImage: "mappin.and.ellipse")
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ .padding(.vertical, 4)
+ }
+
+ private var priceLine: String {
+ "\(listing.unitPriceAmount) \(listing.unitPriceCurrency) / \(listing.unitPriceUnit)"
+ }
+
+ private var binLine: String {
+ let label = listing.binDisplayLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let base = "Bin \(listing.binDisplayAmount) \(listing.binDisplayUnit)"
+ if let label, !label.isEmpty {
+ return "\(base) \(label)"
+ }
+ return base
+ }
+}
diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift
@@ -18,35 +18,44 @@ struct PostFeedView: View {
}
}
- Section {
- ForEach(vm.posts, id: \.id) { item in
- FeedPostRow(
- post: item,
- isExpanded: vm.expandedReplyFor == item.id,
- onToggleReply: { vm.toggleReply(for: item.id) }
- )
- .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
-
- if vm.expandedReplyFor == item.id {
- InlineReplyComposer(
- text: vm.bindingForReply(item.id),
- isSending: vm.sendingReplyFor.contains(item.id),
- onCancel: { vm.expandedReplyFor = nil },
- onSend: {
- vm.sendReply(app: app, to: item) { title, message in
- resultTitle = title
- resultMessage = message
- showResult = true
- }
- }
+ if vm.posts.isEmpty && vm.errorMessage == nil && !vm.isLoading {
+ ContentUnavailableView(
+ "No Posts Yet",
+ systemImage: "text.bubble",
+ description: Text("Connect to relays to load the public feed.")
+ )
+ .listRowBackground(Color.clear)
+ } else {
+ Section {
+ ForEach(vm.posts, id: \.id) { item in
+ FeedPostRow(
+ post: item,
+ isExpanded: vm.expandedReplyFor == item.id,
+ onToggleReply: { vm.toggleReply(for: item.id) }
)
- .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16))
- .listRowSeparator(.hidden)
+ .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
+
+ if vm.expandedReplyFor == item.id {
+ InlineReplyComposer(
+ text: vm.bindingForReply(item.id),
+ isSending: vm.sendingReplyFor.contains(item.id),
+ onCancel: { vm.expandedReplyFor = nil },
+ onSend: {
+ vm.sendReply(app: app, to: item) { title, message in
+ resultTitle = title
+ resultMessage = message
+ showResult = true
+ }
+ }
+ )
+ .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16))
+ .listRowSeparator(.hidden)
+ }
+ }
+ } footer: {
+ if app.relayConnectedCount == 0 {
+ Text("No relays connected. Configure and connect to load posts.")
}
- }
- } footer: {
- if app.relayConnectedCount == 0 {
- Text("No relays connected. Configure and connect to load posts.")
}
}
}
@@ -57,6 +66,13 @@ struct PostFeedView: View {
if vm.isLoading { ProgressView() }
}
ToolbarItem(placement: .topBarTrailing) {
+ NavigationLink {
+ PostCreateView()
+ } label: {
+ Image(systemName: "square.and.pencil")
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
Button { Task { await vm.refresh(app: app) } } label: {
Image(systemName: "arrow.clockwise")
}
@@ -64,7 +80,7 @@ struct PostFeedView: View {
}
}
.task { vm.onAppear(app: app) }
- .onDisappear { vm.onDisappear() }
+ .onDisappear { vm.onDisappear(app: app) }
.refreshable { await vm.refresh(app: app) }
.alert(resultTitle, isPresented: $showResult) {
Button("OK", role: .cancel) { }
diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift
@@ -16,12 +16,13 @@ final class PostFeedViewModel: ObservableObject {
if posts.isEmpty {
Task { await load(app: app) }
}
- startLiveLoop(app: app)
+ startStream(app: app)
}
- func onDisappear() {
+ func onDisappear(app: AppState) {
liveTask?.cancel()
liveTask = nil
+ Task { try? app.radroots.nostrStopPostStream() }
}
func load(app: AppState) async {
@@ -98,51 +99,39 @@ final class PostFeedViewModel: ObservableObject {
}
- private func startLiveLoop(app: AppState) {
+ private func startStream(app: AppState) {
guard liveTask == nil else { return }
liveTask = Task { @MainActor [weak self] in
guard let self else { return }
var knownIds = Set(posts.map(\.id))
- var since = posts.map(\.publishedAt).max()
+ let since = posts.map(\.publishedAt).max()
+ do {
+ try app.radroots.nostrStartPostStream(sinceUnix: since)
+ } catch {
+ errorMessage = String(describing: error)
+ }
while !Task.isCancelled {
if app.relayConnectedCount == 0 {
- try? await Task.sleep(for: .seconds(2))
+ try? await Task.sleep(for: .seconds(1))
continue
}
- guard let rt = app.radroots.runtime else {
- try? await Task.sleep(for: .seconds(2))
- continue
+
+ if knownIds.count != posts.count {
+ knownIds = Set(posts.map(\.id))
}
- let currentSince = since
- let fetchResult: Result<[NostrPostEventMetadata], Error> = await Task.detached { @Sendable in
- do {
- let items = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: currentSince)
- return .success(items)
- } catch {
- return .failure(error)
- }
- }.value
-
- if Task.isCancelled { break }
-
- switch fetchResult {
- case .failure:
- break
- case .success(let fetched):
- let newOnes = fetched.filter { !knownIds.contains($0.id) }
- if !newOnes.isEmpty {
- knownIds.formUnion(newOnes.map(\.id))
- let maxTs = newOnes.map(\.publishedAt).max()
- posts = (newOnes + posts).sorted { $0.publishedAt > $1.publishedAt }
- if let m = maxTs {
- since = max(since ?? 0, m)
+ if let event = app.radroots.nostrNextPostStreamEvent() {
+ if knownIds.insert(event.id).inserted {
+ posts.insert(event, at: 0)
+ posts.sort { $0.publishedAt > $1.publishedAt }
+ if posts.count > 200 {
+ posts = Array(posts.prefix(200))
}
}
+ } else {
+ try? await Task.sleep(for: .milliseconds(300))
}
-
- try? await Task.sleep(for: .seconds(3))
}
}
}
diff --git a/Radroots/Views/SettingsView.swift b/Radroots/Views/SettingsView.swift
@@ -15,6 +15,29 @@ struct SettingsView: View {
Text("No key configured")
.foregroundStyle(.secondary)
}
+
+ NavigationLink {
+ ProfileView()
+ } label: {
+ Label("Profile", systemImage: "person.crop.circle")
+ }
+ }
+
+ Section("Network") {
+ NavigationLink {
+ RelaysView()
+ } label: {
+ Label("Relays", systemImage: "dot.radiowaves.left.and.right")
+ }
+ }
+
+ Section("Trade") {
+ if let rhi = TradeSettings.rhiPubkeyOptional {
+ CopyRow(title: "RHI Pubkey", value: rhi)
+ } else {
+ Text("Set RR_TRADE_RHI_PUBKEY to enable trade flows.")
+ .foregroundStyle(.secondary)
+ }
}
if app.hasKey {
@@ -24,6 +47,8 @@ struct SettingsView: View {
} label: {
Label("Export Secret Hex (Danger)", systemImage: "square.and.arrow.up")
}
+ } header: {
+ Text("Security")
} footer: {
if let exportError {
Text(exportError).foregroundStyle(.red)
@@ -34,6 +59,7 @@ struct SettingsView: View {
}
}
.listStyle(.insetGrouped)
+ .inlineNavigationTitle("Settings")
}
private func exportSecretHex() {
diff --git a/Radroots/Views/SetupView.swift b/Radroots/Views/SetupView.swift
@@ -8,57 +8,33 @@ struct SetupView: View {
var onSuccess: (() -> Void)? = nil
+ @State private var step: Step = .welcome
@State private var isWorking = false
@State private var errorMessage: String?
var body: some View {
- VStack(spacing: 24) {
- Image(systemName: "key.fill")
- .font(.system(size: 60, weight: .bold))
- Text("Set up your Nostr Identity")
- .font(.title2.weight(.semibold))
-
- if let errorMessage {
- Text(errorMessage)
- .foregroundStyle(.red)
- .multilineTextAlignment(.center)
- .padding(.horizontal)
- }
-
- VStack(spacing: 12) {
- Button {
- generateKey()
- } label: {
- HStack {
- if isWorking { ProgressView().padding(.trailing, 8) }
- Text("Generate New Key")
- .fontWeight(.semibold)
+ ZStack {
+ if step == .welcome {
+ SetupWelcomeView {
+ withAnimation(.easeInOut(duration: 0.25)) {
+ step = .keySetup
}
- .frame(maxWidth: .infinity)
- }
- .buttonStyle(.borderedProminent)
- .disabled(isWorking)
-
- Button {
- importFromClipboard()
- } label: {
- Text("Import Secret Hex from Clipboard")
- .frame(maxWidth: .infinity)
}
- .buttonStyle(.bordered)
- .disabled(isWorking)
+ .transition(.opacity.combined(with: .move(edge: .leading)))
}
- .padding(.top, 8)
- Spacer()
-
- Text("Your private key is stored securely in the iOS Keychain.")
- .font(.footnote)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
+ if step == .keySetup {
+ SetupKeyView(
+ isWorking: isWorking,
+ errorMessage: errorMessage,
+ onGenerate: generateKey,
+ onImport: importFromClipboard
+ )
+ .transition(.opacity.combined(with: .move(edge: .trailing)))
+ }
}
- .padding()
- .inlineNavigationTitle("Setup")
+ .animation(.easeInOut(duration: 0.25), value: step)
+ .toolbar(.hidden, for: .navigationBar)
}
private func generateKey() {
@@ -103,3 +79,125 @@ struct SetupView: View {
}
}
}
+
+private enum Step {
+ case welcome
+ case keySetup
+}
+
+private struct SetupWelcomeView: View {
+ let onContinue: () -> Void
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Spacer()
+
+ Circle()
+ .fill(Color(.systemGray5))
+ .frame(width: 120, height: 120)
+ .overlay(
+ Circle()
+ .strokeBorder(Color(.systemGray4), lineWidth: 1)
+ )
+
+ Text(Ls.setupGreetingHeader)
+ .font(.title.weight(.semibold))
+ .multilineTextAlignment(.center)
+
+ Text(Ls.setupGreetingHeaderSub)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+
+ Spacer()
+
+ Button {
+ onContinue()
+ } label: {
+ Text("Continue")
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ }
+ .padding()
+ }
+}
+
+private struct SetupKeyView: View {
+ let isWorking: Bool
+ let errorMessage: String?
+ let onGenerate: () -> Void
+ let onImport: () -> Void
+
+ var body: some View {
+ VStack(spacing: 20) {
+ VStack(spacing: 10) {
+ Image(systemName: "key.fill")
+ .font(.system(size: 44, weight: .semibold))
+ .foregroundStyle(.secondary)
+
+ Text("Set up your Nostr identity")
+ .font(.title2.weight(.semibold))
+ .multilineTextAlignment(.center)
+
+ Text("Generate a new key or import an existing secret to get started.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+
+ if let errorMessage {
+ Text(errorMessage)
+ .foregroundStyle(.red)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal)
+ }
+
+ if isWorking {
+ ProgressView()
+ .controlSize(.large)
+ }
+
+ VStack(spacing: 12) {
+ Button {
+ onGenerate()
+ } label: {
+ HStack(spacing: 10) {
+ Image(systemName: "sparkles")
+ Text("Generate New Key")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(isWorking)
+
+ Button {
+ onImport()
+ } label: {
+ HStack(spacing: 10) {
+ Image(systemName: "doc.on.clipboard")
+ Text("Import Secret Hex")
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ .disabled(isWorking)
+ }
+ .padding(.top, 4)
+
+ Spacer()
+
+ Text("Your private key is stored securely in the iOS Keychain.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding()
+ }
+}
diff --git a/Radroots/Views/TradeListingCreateView.swift b/Radroots/Views/TradeListingCreateView.swift
@@ -0,0 +1,285 @@
+import SwiftUI
+import RadrootsKit
+
+struct TradeListingCreateView: View {
+ @EnvironmentObject private var app: AppState
+ @Environment(\.dismiss) private var dismiss
+ private var onCreated: (() -> Void)?
+
+ @State private var draft = ListingDraftState()
+ @State private var isPosting = false
+ @State private var errorMessage: String?
+ @FocusState private var focusedField: Field?
+
+ init(onCreated: (() -> Void)? = nil) {
+ self.onCreated = onCreated
+ }
+
+ var body: some View {
+ Form {
+ Section("Farm") {
+ TextField("Farm pubkey", text: $draft.farmPubkey)
+ .textInputAutocapitalization(.none)
+ .autocorrectionDisabled()
+ .submitLabel(.next)
+ .focused($focusedField, equals: .farmPubkey)
+ .onSubmit { focusedField = .farmDTag }
+
+ TextField("Farm id", text: $draft.farmDTag)
+ .textInputAutocapitalization(.none)
+ .autocorrectionDisabled()
+ .submitLabel(.next)
+ .focused($focusedField, equals: .farmDTag)
+ .onSubmit { focusedField = .title }
+ }
+
+ Section("Listing") {
+ TextField("Title", text: $draft.title)
+ .textInputAutocapitalization(.words)
+ .submitLabel(.next)
+ .focused($focusedField, equals: .title)
+ .onSubmit { focusedField = .description }
+
+ TextEditor(text: $draft.description)
+ .frame(minHeight: 120)
+ .focused($focusedField, equals: .description)
+ }
+
+ Section("Product") {
+ TextField("Category", text: $draft.category)
+ .textInputAutocapitalization(.words)
+ .submitLabel(.next)
+ .focused($focusedField, equals: .category)
+ .onSubmit { focusedField = .unitPrice }
+ }
+
+ Section("Pricing") {
+ TextField("Unit price", text: $draft.unitPrice)
+ .keyboardType(.decimalPad)
+ .focused($focusedField, equals: .unitPrice)
+
+ TextField("Currency", text: $draft.currency)
+ .textInputAutocapitalization(.characters)
+ .autocorrectionDisabled()
+ .focused($focusedField, equals: .currency)
+
+ HStack {
+ TextField("Bin size", text: $draft.binDisplayAmount)
+ .keyboardType(.decimalPad)
+ .focused($focusedField, equals: .binDisplayAmount)
+ Picker("Unit", selection: $draft.binDisplayUnit) {
+ ForEach(ListingDraftState.UnitOption.allCases, id: \.self) { unit in
+ Text(unit.label).tag(unit)
+ }
+ }
+ .pickerStyle(.menu)
+ }
+
+ TextField("Bin label (optional)", text: $draft.binLabel)
+ .textInputAutocapitalization(.words)
+ .focused($focusedField, equals: .binLabel)
+
+ TextField("Inventory available", text: $draft.inventory)
+ .keyboardType(.decimalPad)
+ .focused($focusedField, equals: .inventory)
+ }
+
+ Section("Delivery") {
+ Picker("Method", selection: $draft.deliveryMethod) {
+ ForEach(ListingDraftState.DeliveryMethod.allCases, id: \.self) { method in
+ Text(method.label).tag(method)
+ }
+ }
+
+ TextField("Location", text: $draft.locationPrimary)
+ .textInputAutocapitalization(.words)
+ .focused($focusedField, equals: .locationPrimary)
+
+ TextField("City", text: $draft.locationCity)
+ .textInputAutocapitalization(.words)
+ .focused($focusedField, equals: .locationCity)
+
+ TextField("Region", text: $draft.locationRegion)
+ .textInputAutocapitalization(.words)
+ .focused($focusedField, equals: .locationRegion)
+
+ TextField("Country", text: $draft.locationCountry)
+ .textInputAutocapitalization(.characters)
+ .focused($focusedField, equals: .locationCountry)
+ }
+
+ Section {
+ SectionWideButton("Publish Listing", enabled: canPublish, isProminent: true) {
+ publish()
+ }
+ } footer: {
+ if let errorMessage {
+ Text(errorMessage).foregroundStyle(.red)
+ } else if app.relayConnectedCount == 0 {
+ Text("No relays connected. Configure relays before publishing.")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .inlineNavigationTitle("New Listing")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if isPosting { ProgressView() }
+ }
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button("Done") { focusedField = nil }
+ }
+ }
+ }
+
+ private var canPublish: Bool {
+ app.relayConnectedCount > 0 && !isPosting && draft.isValid
+ }
+
+ private func publish() {
+ guard let rt = app.radroots.runtime else { return }
+ errorMessage = nil
+ isPosting = true
+ let draftValue = draft.toTradeListingDraft()
+
+ Task { @MainActor in
+ let result: Result<String, Error> = await Task.detached { @Sendable in
+ do {
+ return .success(try rt.tradeListingPublish(draft: draftValue))
+ } catch {
+ return .failure(error)
+ }
+ }.value
+
+ switch result {
+ case .success:
+ isPosting = false
+ onCreated?()
+ dismiss()
+ case .failure(let error):
+ isPosting = false
+ errorMessage = String(describing: error)
+ }
+ }
+ }
+}
+
+private enum Field: Hashable {
+ case farmPubkey
+ case farmDTag
+ case title
+ case description
+ case category
+ case unitPrice
+ case currency
+ case binDisplayAmount
+ case binLabel
+ case inventory
+ case locationPrimary
+ case locationCity
+ case locationRegion
+ case locationCountry
+}
+
+private struct ListingDraftState {
+ enum UnitOption: String, CaseIterable {
+ case each
+ case lb
+ case oz
+ case g
+ case kg
+ case l
+ case ml
+
+ var label: String {
+ switch self {
+ case .each: return "Each"
+ case .lb: return "lb"
+ case .oz: return "oz"
+ case .g: return "g"
+ case .kg: return "kg"
+ case .l: return "L"
+ case .ml: return "mL"
+ }
+ }
+ }
+
+ enum DeliveryMethod: String, CaseIterable {
+ case pickup
+ case localDelivery
+ case shipping
+
+ var label: String {
+ switch self {
+ case .pickup: return "Pickup"
+ case .localDelivery: return "Local delivery"
+ case .shipping: return "Shipping"
+ }
+ }
+
+ var rawValueString: String {
+ switch self {
+ case .pickup: return "pickup"
+ case .localDelivery: return "local_delivery"
+ case .shipping: return "shipping"
+ }
+ }
+ }
+
+ var title: String = ""
+ var description: String = ""
+ var category: String = ""
+ var farmPubkey: String = ""
+ var farmDTag: String = ""
+ var binDisplayUnit: UnitOption = .lb
+ var binDisplayAmount: String = "1"
+ var unitPrice: String = ""
+ var currency: String = "USD"
+ var binLabel: String = ""
+ var inventory: String = ""
+ var deliveryMethod: DeliveryMethod = .shipping
+ var locationPrimary: String = ""
+ var locationCity: String = ""
+ var locationRegion: String = ""
+ var locationCountry: String = ""
+
+ var isValid: Bool {
+ !farmPubkey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !farmDTag.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !category.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !binDisplayAmount.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !unitPrice.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !currency.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !inventory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
+ !locationPrimary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ func toTradeListingDraft() -> TradeListingDraft {
+ let trimmedLabel = binLabel.trimmingCharacters(in: .whitespacesAndNewlines)
+ TradeListingDraft(
+ listingId: nil,
+ farmPubkey: farmPubkey,
+ farmDTag: farmDTag,
+ title: title,
+ description: description,
+ category: category,
+ binDisplayAmount: binDisplayAmount,
+ binDisplayUnit: binDisplayUnit.rawValue,
+ unitPrice: unitPrice,
+ currency: currency,
+ binLabel: trimmedLabel.isEmpty ? nil : trimmedLabel,
+ binId: nil,
+ inventory: inventory,
+ deliveryMethod: deliveryMethod.rawValueString,
+ locationPrimary: locationPrimary,
+ locationCity: locationCity.isEmpty ? nil : locationCity,
+ locationRegion: locationRegion.isEmpty ? nil : locationRegion,
+ locationCountry: locationCountry.isEmpty ? nil : locationCountry
+ )
+ }
+
+}
diff --git a/Radroots/Views/TradeListingDetailView.swift b/Radroots/Views/TradeListingDetailView.swift
@@ -0,0 +1,248 @@
+import SwiftUI
+import RadrootsKit
+
+@MainActor
+final class TradeListingDetailViewModel: ObservableObject {
+ let listing: TradeListingSummary
+ @Published var messages: [TradeListingMessageSummary] = []
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+ @Published var orderId: String?
+
+ init(listing: TradeListingSummary) {
+ self.listing = listing
+ }
+
+ func refresh(app: AppState) async {
+ guard let rt = app.radroots.runtime else { return }
+ isLoading = true
+ errorMessage = nil
+
+ let listingAddr = listing.listingAddr
+ let orderId = self.orderId
+
+ let result: Result<[TradeListingMessageSummary], Error> = await Task.detached { @Sendable in
+ do {
+ return .success(
+ try rt.tradeListingFetchMessages(
+ listingAddr: listingAddr,
+ orderId: orderId,
+ limit: 80,
+ sinceUnix: nil
+ )
+ )
+ } catch {
+ return .failure(error)
+ }
+ }.value
+
+ switch result {
+ case .success(let items):
+ messages = items
+ isLoading = false
+ case .failure(let error):
+ errorMessage = String(describing: error)
+ isLoading = false
+ }
+ }
+}
+
+struct TradeListingDetailView: View {
+ @EnvironmentObject private var app: AppState
+ let listing: TradeListingSummary
+ @StateObject private var vm: TradeListingDetailViewModel
+ @State private var showOrderSheet = false
+ @State private var showValidationAlert = false
+ @State private var validationMessage = ""
+
+ init(listing: TradeListingSummary) {
+ self.listing = listing
+ _vm = StateObject(wrappedValue: TradeListingDetailViewModel(listing: listing))
+ }
+
+ var body: some View {
+ List {
+ Section("Listing") {
+ LabeledContent("Title", value: listing.title)
+ if !listing.description.isEmpty {
+ LabeledContent("Description", value: listing.description)
+ }
+ LabeledContent("Category", value: listing.productType)
+ if !listing.availability.isEmpty {
+ LabeledContent("Availability", value: listing.availability.capitalized)
+ }
+ }
+
+ Section("Pricing") {
+ LabeledContent("Unit price", value: priceLine)
+ LabeledContent("Bin size", value: binLine)
+ LabeledContent("Inventory", value: listing.inventoryAvailable)
+ }
+
+ Section("Delivery") {
+ LabeledContent("Method", value: listing.deliveryMethod.capitalized)
+ LabeledContent("Location", value: listing.location)
+ }
+
+ Section {
+ SectionWideButton("Validate Listing", enabled: canUseTrade) {
+ sendValidationRequest()
+ }
+
+ SectionWideButton("Request Order", enabled: canUseTrade, isProminent: true) {
+ showOrderSheet = true
+ }
+
+ if let orderId = vm.orderId {
+ LabeledContent("Order ID", value: orderId)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ } header: {
+ Text("Actions")
+ } footer: {
+ if !canUseTrade {
+ Text(tradeDisabledMessage)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Activity") {
+ if let error = vm.errorMessage {
+ Text(error)
+ .foregroundStyle(.red)
+ .font(.footnote)
+ }
+
+ if vm.messages.isEmpty {
+ ContentUnavailableView(
+ "No Activity Yet",
+ systemImage: "bubble.left.and.text.bubble.right",
+ description: Text("Validation and order updates appear here.")
+ )
+ .listRowBackground(Color.clear)
+ } else {
+ ForEach(vm.messages) { message in
+ TradeListingMessageRow(message: message)
+ }
+ }
+ }
+ }
+ .listStyle(.insetGrouped)
+ .inlineNavigationTitle(listing.title)
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if vm.isLoading { ProgressView() }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ Task { await vm.refresh(app: app) }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ }
+ }
+ .task { await vm.refresh(app: app) }
+ .refreshable { await vm.refresh(app: app) }
+ .sheet(isPresented: $showOrderSheet) {
+ NavigationStack {
+ TradeOrderRequestView(listing: listing) { result in
+ vm.orderId = result.orderId
+ Task { await vm.refresh(app: app) }
+ }
+ }
+ }
+ .alert("Validation Request", isPresented: $showValidationAlert) {
+ Button("OK", role: .cancel) { }
+ } message: {
+ Text(validationMessage)
+ }
+ }
+
+ private var priceLine: String {
+ "\(listing.unitPriceAmount) \(listing.unitPriceCurrency) / \(listing.unitPriceUnit)"
+ }
+
+ private var binLine: String {
+ let label = listing.binDisplayLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let base = "\(listing.binDisplayAmount) \(listing.binDisplayUnit)"
+ if let label, !label.isEmpty {
+ return "\(base) \(label)"
+ }
+ return base
+ }
+
+ private var canUseTrade: Bool {
+ app.relayConnectedCount > 0 && TradeSettings.rhiPubkeyOptional != nil
+ }
+
+ private var tradeDisabledMessage: String {
+ if app.relayConnectedCount == 0 {
+ return "Connect to relays to use trade flows."
+ }
+ return "Set RR_TRADE_RHI_PUBKEY to enable trade requests."
+ }
+
+ private func sendValidationRequest() {
+ guard let rt = app.radroots.runtime else { return }
+ guard let rhiPubkey = TradeSettings.rhiPubkeyOptional else {
+ validationMessage = "Set RR_TRADE_RHI_PUBKEY to enable validation."
+ showValidationAlert = true
+ return
+ }
+
+ Task { @MainActor in
+ let result: Result<String, Error> = await Task.detached { @Sendable in
+ do {
+ return .success(
+ try rt.tradeListingSendValidationRequest(
+ listingEventId: listing.eventId,
+ sellerPubkey: listing.sellerPubkey,
+ listingId: listing.listingId,
+ recipientPubkey: rhiPubkey
+ )
+ )
+ } catch {
+ return .failure(error)
+ }
+ }.value
+
+ switch result {
+ case .success(let id):
+ validationMessage = "Validation request sent: \(id)"
+ case .failure(let error):
+ validationMessage = "Validation failed: \(error)"
+ }
+ showValidationAlert = true
+ }
+ }
+}
+
+private struct TradeListingMessageRow: View {
+ let message: TradeListingMessageSummary
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(message.summary)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.primary)
+ HStack {
+ Text(message.messageType.replacingOccurrences(of: "_", with: " ").capitalized)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text(relativeTime(message.publishedAt))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(.vertical, 4)
+ }
+
+ private func relativeTime(_ unix: UInt64) -> String {
+ let d = Date(timeIntervalSince1970: TimeInterval(unix))
+ let f = RelativeDateTimeFormatter()
+ f.unitsStyle = .abbreviated
+ return f.localizedString(for: d, relativeTo: Date())
+ }
+}
diff --git a/Radroots/Views/TradeOrderRequestView.swift b/Radroots/Views/TradeOrderRequestView.swift
@@ -0,0 +1,149 @@
+import SwiftUI
+import Foundation
+import RadrootsKit
+
+struct TradeOrderRequestView: View {
+ @EnvironmentObject private var app: AppState
+ @Environment(\.dismiss) private var dismiss
+ let listing: TradeListingSummary
+ private let onComplete: (TradeOrderSendResult) -> Void
+
+ @State private var binCount: String
+ @State private var notes: String = ""
+ @State private var isSending = false
+ @State private var errorMessage: String?
+ @FocusState private var focused: Bool
+
+ init(listing: TradeListingSummary, onComplete: @escaping (TradeOrderSendResult) -> Void) {
+ self.listing = listing
+ self.onComplete = onComplete
+ _binCount = State(initialValue: "1")
+ }
+
+ var body: some View {
+ Form {
+ Section("Order") {
+ TextField("Bin count", text: $binCount)
+ .keyboardType(.numberPad)
+ .focused($focused)
+ LabeledContent("Bin size", value: binLine)
+ LabeledContent("Unit price", value: unitPriceLine)
+ if let total = totalPriceLine {
+ LabeledContent("Estimated total", value: total)
+ }
+ }
+
+ Section("Notes") {
+ TextEditor(text: $notes)
+ .frame(minHeight: 100)
+ }
+
+ Section {
+ SectionWideButton("Send Order Request", enabled: canSend, isProminent: true) {
+ sendOrder()
+ }
+ } footer: {
+ if let errorMessage {
+ Text(errorMessage).foregroundStyle(.red)
+ } else if TradeSettings.rhiPubkeyOptional == nil {
+ Text("Set RR_TRADE_RHI_PUBKEY to enable order requests.")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+ .scrollDismissesKeyboard(.interactively)
+ .inlineNavigationTitle("Order Request")
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ if isSending { ProgressView() }
+ }
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button("Done") { focused = false }
+ }
+ }
+ .onAppear { focused = true }
+ }
+
+ private var unitPriceLine: String {
+ "\(listing.unitPriceAmount) \(listing.unitPriceCurrency) / \(listing.unitPriceUnit)"
+ }
+
+ private var binLine: String {
+ let label = listing.binDisplayLabel?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let base = "\(listing.binDisplayAmount) \(listing.binDisplayUnit)"
+ if let label, !label.isEmpty {
+ return "\(base) \(label)"
+ }
+ return base
+ }
+
+ private var canSend: Bool {
+ app.relayConnectedCount > 0 &&
+ !isSending &&
+ TradeSettings.rhiPubkeyOptional != nil &&
+ parsedBinCount != nil
+ }
+
+ private var totalPriceLine: String? {
+ guard let countValue = parsedBinCount,
+ let unitPrice = Decimal(string: listing.unitPriceAmount),
+ let binAmount = Decimal(string: listing.binDisplayAmount) else {
+ return nil
+ }
+ let count = Decimal(Int(countValue))
+ let total = count * unitPrice * binAmount
+ return "\(total) \(listing.unitPriceCurrency)"
+ }
+
+ private func sendOrder() {
+ guard let rt = app.radroots.runtime else { return }
+ guard let rhiPubkey = TradeSettings.rhiPubkeyOptional else {
+ errorMessage = "Missing RHI pubkey."
+ return
+ }
+ errorMessage = nil
+ isSending = true
+
+ let trimmedNotes = notes.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let countValue = parsedBinCount else {
+ errorMessage = "Bin count must be a whole number."
+ isSending = false
+ return
+ }
+ let trimmedCount = String(countValue)
+ let draft = TradeOrderDraft(
+ listingAddr: listing.listingAddr,
+ sellerPubkey: listing.sellerPubkey,
+ binId: listing.primaryBinId,
+ binCount: trimmedCount,
+ notes: trimmedNotes.isEmpty ? nil : trimmedNotes,
+ orderId: nil,
+ recipientPubkey: rhiPubkey
+ )
+
+ Task { @MainActor in
+ let result: Result<TradeOrderSendResult, Error> = await Task.detached { @Sendable in
+ do {
+ return .success(try rt.tradeListingSendOrderRequest(draft: draft))
+ } catch {
+ return .failure(error)
+ }
+ }.value
+
+ switch result {
+ case .success(let out):
+ isSending = false
+ onComplete(out)
+ dismiss()
+ case .failure(let error):
+ isSending = false
+ errorMessage = String(describing: error)
+ }
+ }
+ }
+
+ private var parsedBinCount: UInt32? {
+ UInt32(binCount.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+}
diff --git a/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift b/RadrootsKit/Sources/RadrootsKit/BuildConfig.swift
@@ -6,6 +6,7 @@ enum BuildConfigKey: String {
case logFileEnabled = "RR_LOG_FILE_ENABLED"
case logFileName = "RR_LOG_FILE_NAME"
case nostrRelays = "NOSTR_RELAYS"
+ case tradeRhiPubkey = "RR_TRADE_RHI_PUBKEY"
}
enum BuildConfig {
diff --git a/RadrootsKit/Sources/RadrootsKit/Nostr.swift b/RadrootsKit/Sources/RadrootsKit/Nostr.swift
@@ -85,7 +85,7 @@ public extension Radroots {
}
@MainActor
-private extension Radroots {
+extension Radroots {
func requireRuntime() throws -> RadrootsRuntime {
guard let rt = runtime else { throw RadrootsRuntimeError.runtimeNotStarted }
return rt
diff --git a/RadrootsKit/Sources/RadrootsKit/TradeListing.swift b/RadrootsKit/Sources/RadrootsKit/TradeListing.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+@MainActor
+public extension Radroots {
+ func tradeListingPublish(draft: TradeListingDraft) throws -> NostrEventId {
+ let rt = try requireRuntime()
+ let id = try rt.tradeListingPublish(draft: draft)
+ return NostrEventId(id)
+ }
+
+ func tradeListingsFetch(limit: UInt16, sinceUnix: UInt64? = nil) throws -> [TradeListingSummary] {
+ let rt = try requireRuntime()
+ return try rt.tradeListingsFetch(limit: limit, sinceUnix: sinceUnix)
+ }
+
+ func tradeListingSendValidationRequest(
+ listingEventId: String,
+ sellerPubkey: String,
+ listingId: String,
+ recipientPubkey: String
+ ) throws -> NostrEventId {
+ let rt = try requireRuntime()
+ let id = try rt.tradeListingSendValidationRequest(
+ listingEventId: listingEventId,
+ sellerPubkey: sellerPubkey,
+ listingId: listingId,
+ recipientPubkey: recipientPubkey
+ )
+ return NostrEventId(id)
+ }
+
+ func tradeListingSendOrderRequest(draft: TradeOrderDraft) throws -> TradeOrderSendResult {
+ let rt = try requireRuntime()
+ return try rt.tradeListingSendOrderRequest(draft: draft)
+ }
+
+ func tradeListingFetchMessages(
+ listingAddr: String,
+ orderId: String? = nil,
+ limit: UInt16,
+ sinceUnix: UInt64? = nil
+ ) throws -> [TradeListingMessageSummary] {
+ let rt = try requireRuntime()
+ return try rt.tradeListingFetchMessages(
+ listingAddr: listingAddr,
+ orderId: orderId,
+ limit: limit,
+ sinceUnix: sinceUnix
+ )
+ }
+}
+
+extension TradeListingSummary: Identifiable {
+ public var id: String { eventId }
+}
+
+extension TradeListingMessageSummary: Identifiable {
+ public var id: String { eventId }
+}
diff --git a/RadrootsKit/Sources/RadrootsKit/TradeSettings.swift b/RadrootsKit/Sources/RadrootsKit/TradeSettings.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+public enum TradeSettingsError: LocalizedError {
+ case noRhiPubkeyConfigured
+
+ public var errorDescription: String? {
+ "No trade RHI pubkey configured. Set build setting 'RR_TRADE_RHI_PUBKEY'."
+ }
+}
+
+public enum TradeSettings {
+ public static func rhiPubkey() throws -> String {
+ guard let value = rhiPubkeyOptional else {
+ throw TradeSettingsError.noRhiPubkeyConfigured
+ }
+ return value
+ }
+
+ public static var rhiPubkeyOptional: String? {
+ BuildConfig.string(.tradeRhiPubkey)
+ .flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .flatMap { $0.isEmpty ? nil : $0 }
+ }
+}