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:
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