field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | LICENSE

commit ccdbdcf88355d226de8e32fbad70da1e504cb2f9
parent 9beecc9c6a5218a47efd6820c79af8465013feb1
Author: triesap <triesap@radroots.dev>
Date:   Mon,  6 Oct 2025 14:13:17 +0100

Refactor post feed with inline reply composer. Integrate structured debug dump utility for post metadata logging.

Diffstat:
MRadroots.xcodeproj/project.pbxproj | 20++++++++++++--------
ARadroots/Shared/Logging/DebugDump.swift | 44++++++++++++++++++++++++++++++++++++++++++++
MRadroots/Views/PostFeedView.swift | 225+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
ARadroots/Views/PostFeedViewModel.swift | 0
4 files changed, 255 insertions(+), 34 deletions(-)

diff --git a/Radroots.xcodeproj/project.pbxproj b/Radroots.xcodeproj/project.pbxproj @@ -13,8 +13,9 @@ 2B3886FD26434A54F3726591 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 8D19AA8D515FC8F5D2407378 /* Localizable.strings */; }; 2B6ACA26689B355CECBFFB57 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16A7641E5C643B4B36CFEDA8 /* SetupView.swift */; }; 360F23EFE80FDBDC6983FB15 /* AppEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D448C9655B708CA3FA8712B9 /* AppEntry.swift */; }; - 53694F65BFDDA2B5213063EE /* PostCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BADD960784613FCC0EA1D14 /* PostCreateView.swift */; }; + 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */; }; 7FD8FB018DA09568303194B2 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE61264E2C98E73828E8680 /* Strings.swift */; }; + 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE0EB327C10171444553378 /* PostFeedView.swift */; }; 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4C7DE4207398DE242519F9C /* CopyButton.swift */; }; A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17CA8F5611075F60F214A00 /* SectionWideButton.swift */; }; B971351ABE8E79A472B4DC7D /* View+Nav.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B0C9861CD86EAD3CAD549E /* View+Nav.swift */; }; @@ -26,14 +27,13 @@ DCE468F668A3C346E716B04C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CCF0F7B3C57D8D770F178329 /* Assets.xcassets */; }; E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA7BAA021EE13E829390B /* Bundle+Build.swift */; }; EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */; }; - F01E2EF766801B5D76A0852B /* FeedsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E07205F92CB5B757F7A985C /* FeedsView.swift */; }; + F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */; }; F3E40E5A76B4EA19AC7603D2 /* RadrootsKit in Frameworks */ = {isa = PBXBuildFile; productRef = 2DAD90EBF8EB00ACDD7611CD /* RadrootsKit */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 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>"; }; - 0BADD960784613FCC0EA1D14 /* PostCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCreateView.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>"; }; @@ -46,18 +46,20 @@ 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>"; }; - 8E07205F92CB5B757F7A985C /* FeedsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedsView.swift; 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>"; }; + 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>"; }; B289F4B276245ABE083D777F /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; }; C17CA8F5611075F60F214A00 /* SectionWideButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SectionWideButton.swift; sourceTree = "<group>"; }; C1D9496F9F05A4E79E73A247 /* RelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelaysView.swift; sourceTree = "<group>"; }; C71A93F98C7B93188748B99B /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; + CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCreateView.swift; sourceTree = "<group>"; }; CCF0F7B3C57D8D770F178329 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; D448C9655B708CA3FA8712B9 /* AppEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEntry.swift; sourceTree = "<group>"; }; E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; + E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDump.swift; sourceTree = "<group>"; }; F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostDetailView.swift; sourceTree = "<group>"; }; F4C7DE4207398DE242519F9C /* CopyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CopyButton.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -157,10 +159,10 @@ BD0E20D32DF34D9E7C3EBCD2 /* Views */ = { isa = PBXGroup; children = ( - 8E07205F92CB5B757F7A985C /* FeedsView.swift */, 0A0274A0260D1C04F40C71AF /* HomeView.swift */, + CA8AAF0C0F1723860A8481E0 /* PostCreateView.swift */, F3C0EFACAD213A69C12D5064 /* PostDetailView.swift */, - 0BADD960784613FCC0EA1D14 /* PostCreateView.swift */, + 9AE0EB327C10171444553378 /* PostFeedView.swift */, C71A93F98C7B93188748B99B /* ProfileView.swift */, C1D9496F9F05A4E79E73A247 /* RelaysView.swift */, E1D12A016D1377CDFBFB0F9B /* SettingsView.swift */, @@ -216,6 +218,7 @@ F16A19713274742D956C3A4D /* Logging */ = { isa = PBXGroup; children = ( + E7F9BFC3C8CE2F86FB7DB74B /* DebugDump.swift */, 2FE790CA1CD31208947913B9 /* Logger.swift */, ); path = Logging; @@ -297,11 +300,12 @@ E3864E34D67BAD0744B93180 /* Bundle+Build.swift in Sources */, 9121BD4A3E7C6EF2B21F540F /* CopyButton.swift in Sources */, D3834AF9A4E1327B7DA557F3 /* CopyRow.swift in Sources */, - F01E2EF766801B5D76A0852B /* FeedsView.swift in Sources */, + 4025E63F6603011431B8A0E1 /* DebugDump.swift in Sources */, 1E5B41A3E1F9A7D68F63B079 /* HomeView.swift in Sources */, C512BD267A3E2B8F10FABB3B /* Logger.swift in Sources */, + F32EFF00A8A852F76657FEE1 /* PostCreateView.swift in Sources */, EB7C19F62D7DAB9C044D53AA /* PostDetailView.swift in Sources */, - 53694F65BFDDA2B5213063EE /* PostCreateView.swift in Sources */, + 8B923F78BF5B680C7F6A7CE3 /* PostFeedView.swift in Sources */, 275D4D574BF3B3C1DD746CE7 /* ProfileView.swift in Sources */, 022DA21729F49893319717AA /* RelaysView.swift in Sources */, A1B921027DA7ACD7343BE250 /* SectionWideButton.swift in Sources */, diff --git a/Radroots/Shared/Logging/DebugDump.swift b/Radroots/Shared/Logging/DebugDump.swift @@ -0,0 +1,44 @@ +import Foundation +import RadrootsKit + +enum DebugDump { + static func posts(_ items: [NostrPostEventMetadata], label: String = "PostFeed.kind1") { + let mapped = items.map { + DumpPost(id: $0.id, author: $0.author, publishedAt: $0.publishedAt, content: $0.post.content) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonString = (try? encoder.encode(mapped)).flatMap { String(data: $0, encoding: .utf8) } + let dumpString: String = jsonString ?? { + var s = "" + dump(mapped, to: &s, maxDepth: Int.max, maxItems: Int.max) + return s + }() + RadrootsLogger.debug("\(label) count=\(items.count)") + let chunks = dumpString.chunked(into: 900) + for (i, chunk) in chunks.enumerated() { + RadrootsLogger.debug("\(label) \(i + 1)/\(chunks.count):\n\(chunk)") + } + } + + private struct DumpPost: Codable { + let id: String + let author: String + let publishedAt: UInt64 + let content: String + } +} + +private extension String { + func chunked(into size: Int) -> [String] { + guard size > 0, !isEmpty else { return [self] } + var result: [String] = [] + var idx = startIndex + while idx < endIndex { + let end = index(idx, offsetBy: size, limitedBy: endIndex) ?? endIndex + result.append(String(self[idx..<end])) + idx = end + } + return result + } +} diff --git a/Radroots/Views/PostFeedView.swift b/Radroots/Views/PostFeedView.swift @@ -6,20 +6,41 @@ struct PostFeedView: View { @State private var posts: [NostrPostEventMetadata] = [] @State private var isLoading = false @State private var errorMessage: String? + @State private var expandedReplyFor: String? + @State private var draftReplies: [String: String] = [:] + @State private var sendingReplyFor: Set<String> = [] + @State private var resultTitle: String = "" + @State private var resultMessage: String = "" + @State private var showResult: Bool = false var body: some View { List { if let e = errorMessage { Section { - Text(e).foregroundStyle(.red).font(.footnote) + Text(e) + .foregroundStyle(.red) + .font(.footnote) } } + Section { ForEach(posts, id: \.id) { item in - NavigationLink { - PostDetailView(post: item) - } label: { - PostRow(post: item) + FeedPostRow( + post: item, + isExpanded: expandedReplyFor == item.id, + onToggleReply: { toggleReply(for: item.id) } + ) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + + if expandedReplyFor == item.id { + InlineReplyComposer( + text: bindingForReply(item.id), + isSending: sendingReplyFor.contains(item.id), + onCancel: { expandedReplyFor = nil }, + onSend: { handleReply(item) } + ) + .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16)) + .listRowSeparator(.hidden) } } } footer: { @@ -35,9 +56,7 @@ struct PostFeedView: View { if isLoading { ProgressView() } } ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await load() } - } label: { + Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } .disabled(isLoading) @@ -45,6 +64,11 @@ struct PostFeedView: View { } .task { if posts.isEmpty { await load() } } .refreshable { await load() } + .alert(resultTitle, isPresented: $showResult) { + Button("OK", role: .cancel) { } + } message: { + Text(resultMessage) + } } private func load() async { @@ -56,6 +80,7 @@ struct PostFeedView: View { do { let fetched = try rt.nostrFetchTextNotes(limit: 50, sinceUnix: nil) let sorted = fetched.sorted { $0.publishedAt > $1.publishedAt } + DebugDump.posts(sorted, label: "PostFeed.kind1.displayed") await MainActor.run { posts = sorted isLoading = false @@ -67,37 +92,103 @@ struct PostFeedView: View { } } } + + private func toggleReply(for id: String) { + expandedReplyFor = expandedReplyFor == id ? nil : id + } + + private func bindingForReply(_ id: String) -> Binding<String> { + Binding<String>( + get: { draftReplies[id, default: ""] }, + set: { draftReplies[id] = $0 } + ) + } + + private func handleReply(_ post: NostrPostEventMetadata) { + guard let rt = app.radroots.runtime else { return } + let raw = draftReplies[post.id, default: ""] + let reply = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !reply.isEmpty else { return } + sendingReplyFor.insert(post.id) + + Task { + do { + let id = try rt.nostrPostReply( + parentEventIdHex: post.id, + parentAuthorHex: post.author, + content: reply, + rootEventIdHex: nil as String? + ) + await MainActor.run { + draftReplies[post.id] = "" + expandedReplyFor = nil + sendingReplyFor.remove(post.id) + resultTitle = "Reply Posted" + resultMessage = "Event \(id)" + showResult = true + app.refresh() + } + } catch { + await MainActor.run { + sendingReplyFor.remove(post.id) + resultTitle = "Failed to Post Reply" + resultMessage = String(describing: error) + showResult = true + } + } + } + } } -private struct PostRow: View { +fileprivate struct FeedPostRow: View { let post: NostrPostEventMetadata + let isExpanded: Bool + let onToggleReply: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(post.post.content) - .font(.body) - .multilineTextAlignment(.leading) - HStack(spacing: 8) { - Text(shortAuthor) - .font(.footnote.monospaced()) - .foregroundStyle(.secondary) - Text("·") - .foregroundStyle(.secondary) - Text(dateText) - .font(.footnote) + HStack(alignment: .top, spacing: 12) { + AvatarView(seed: post.author) + .frame(width: 40, height: 40) + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text(shortAuthor(post.author)) + .font(.callout.monospaced()) + .foregroundStyle(.primary) + .lineLimit(1) + Text("·") + .foregroundStyle(.secondary) + Text(relativeTime(post.publishedAt)) + .font(.footnote) + .foregroundStyle(.secondary) + } + Text(post.post.content) + .font(.body) + .multilineTextAlignment(.leading) + HStack(spacing: 24) { + Button(action: onToggleReply) { + HStack(spacing: 6) { + Image(systemName: isExpanded ? "bubble.left.fill" : "bubble.left") + Text("Reply") + } + } + .buttonStyle(.plain) .foregroundStyle(.secondary) + } + .font(.subheadline.weight(.semibold)) + .padding(.top, 2) } } .padding(.vertical, 4) } - private var dateText: String { - let d = Date(timeIntervalSince1970: TimeInterval(post.publishedAt)) - return d.formatted(date: .abbreviated, time: .shortened) + 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()) } - private var shortAuthor: String { - let s = post.author + private func shortAuthor(_ s: String) -> String { let n = s.count if n <= 12 { return s } let prefix = s.prefix(6) @@ -105,3 +196,85 @@ private struct PostRow: View { return "\(prefix)…\(suffix)" } } + +fileprivate struct InlineReplyComposer: View { + @Binding var text: String + let isSending: Bool + let onCancel: () -> Void + let onSend: () -> Void + @FocusState private var focused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ZStack(alignment: .topLeading) { + TextEditor(text: $text) + .font(.body) + .padding(10) + .frame(minHeight: 160, maxHeight: 220, alignment: .topLeading) + .scrollContentBackground(.hidden) + .background(Color.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .focused($focused) + + if text.isEmpty { + Text("Write a reply") + .foregroundStyle(.secondary) + .padding(.top, 16) + .padding(.leading, 16) + .allowsHitTesting(false) + } + } + + HStack { + Spacer() + Button("Cancel", role: .cancel) { onCancel() } + .buttonStyle(.bordered) + Button { + onSend() + } label: { + if isSending { + ProgressView() + } else { + Text("Send").fontWeight(.semibold) + } + } + .buttonStyle(.borderedProminent) + .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending) + } + } + .onAppear { focused = true } + } +} + +fileprivate struct AvatarView: View { + let seed: String + + var body: some View { + ZStack { + Circle().fill(gradient) + Text(initials) + .font(.caption.weight(.bold)) + .foregroundStyle(.white.opacity(0.9)) + } + .accessibilityHidden(true) + } + + private var initials: String { + let trimmed = seed.replacingOccurrences(of: "npub1", with: "") + return trimmed.first.map { String($0).uppercased() } ?? "?" + } + + private var gradient: LinearGradient { + let hash = abs(seed.hashValue) + let hue = Double(hash % 360) / 360.0 + let c1 = Color(hue: hue, saturation: 0.65, brightness: 0.85) + let c2 = Color(hue: (hue + 0.08).truncatingRemainder(dividingBy: 1), + saturation: 0.55, + brightness: 0.75) + return LinearGradient( + gradient: Gradient(colors: [c1, c2]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} diff --git a/Radroots/Views/PostFeedViewModel.swift b/Radroots/Views/PostFeedViewModel.swift