PostFeedView.swift (8379B)
1 import SwiftUI 2 3 struct PostFeedView: View { 4 @EnvironmentObject private var app: AppState 5 @StateObject private var vm = PostFeedViewModel() 6 @State private var resultTitle: String = "" 7 @State private var resultMessage: String = "" 8 @State private var showResult: Bool = false 9 10 var body: some View { 11 List { 12 if let e = vm.errorMessage { 13 Section { 14 Text(e) 15 .foregroundStyle(.red) 16 .font(.footnote) 17 } 18 } 19 20 if vm.posts.isEmpty && vm.errorMessage == nil && !vm.isLoading { 21 ContentUnavailableView( 22 "No Posts Yet", 23 systemImage: "text.bubble", 24 description: Text("Connect to relays to load the public feed.") 25 ) 26 .listRowBackground(Color.clear) 27 } else { 28 Section { 29 ForEach(vm.posts, id: \.id) { item in 30 FeedPostRow( 31 post: item, 32 isExpanded: vm.expandedReplyFor == item.id, 33 onToggleReply: { vm.toggleReply(for: item.id) } 34 ) 35 .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) 36 37 if vm.expandedReplyFor == item.id { 38 InlineReplyComposer( 39 text: vm.bindingForReply(item.id), 40 isSending: vm.sendingReplyFor.contains(item.id), 41 onCancel: { vm.expandedReplyFor = nil }, 42 onSend: { 43 vm.sendReply(app: app, to: item) { title, message in 44 resultTitle = title 45 resultMessage = message 46 showResult = true 47 } 48 } 49 ) 50 .listRowInsets(EdgeInsets(top: 6, leading: 64, bottom: 12, trailing: 16)) 51 .listRowSeparator(.hidden) 52 } 53 } 54 } footer: { 55 if app.relayConnectedCount == 0 { 56 Text("No relays connected. Configure and connect to load posts.") 57 } 58 } 59 } 60 } 61 .listStyle(.insetGrouped) 62 .inlineNavigationTitle("Feed") 63 .accessibilityIdentifier("field_ios.feed") 64 .toolbar { 65 ToolbarItem(placement: .topBarTrailing) { 66 if vm.isLoading { ProgressView() } 67 } 68 ToolbarItem(placement: .topBarTrailing) { 69 NavigationLink { 70 PostCreateView() 71 } label: { 72 Image(systemName: "square.and.pencil") 73 } 74 } 75 ToolbarItem(placement: .topBarTrailing) { 76 Button { Task { await vm.refresh(app: app) } } label: { 77 Image(systemName: "arrow.clockwise") 78 } 79 .disabled(vm.isLoading) 80 } 81 } 82 .task { vm.onAppear(app: app) } 83 .onDisappear { vm.onDisappear(app: app) } 84 .refreshable { await vm.refresh(app: app) } 85 .alert(resultTitle, isPresented: $showResult) { 86 Button("OK", role: .cancel) { } 87 } message: { 88 Text(resultMessage) 89 } 90 } 91 } 92 93 fileprivate struct FeedPostRow: View { 94 let post: NostrPostEventMetadata 95 let isExpanded: Bool 96 let onToggleReply: () -> Void 97 98 var body: some View { 99 HStack(alignment: .top, spacing: 12) { 100 AvatarView(seed: post.author) 101 .frame(width: 40, height: 40) 102 VStack(alignment: .leading, spacing: 6) { 103 HStack(spacing: 6) { 104 Text(shortAuthor(post.author)) 105 .font(.callout.monospaced()) 106 .foregroundStyle(.primary) 107 .lineLimit(1) 108 Text("·") 109 .foregroundStyle(.secondary) 110 Text(relativeTime(post.publishedAt)) 111 .font(.footnote) 112 .foregroundStyle(.secondary) 113 } 114 Text(post.post.content) 115 .font(.body) 116 .multilineTextAlignment(.leading) 117 HStack(spacing: 24) { 118 Button(action: onToggleReply) { 119 HStack(spacing: 6) { 120 Image(systemName: isExpanded ? "bubble.left.fill" : "bubble.left") 121 Text("Reply") 122 } 123 } 124 .buttonStyle(.plain) 125 .foregroundStyle(.secondary) 126 } 127 .font(.subheadline.weight(.semibold)) 128 .padding(.top, 2) 129 } 130 } 131 .padding(.vertical, 4) 132 } 133 134 private func relativeTime(_ unix: UInt64) -> String { 135 let d = Date(timeIntervalSince1970: TimeInterval(unix)) 136 let f = RelativeDateTimeFormatter() 137 f.unitsStyle = .abbreviated 138 return f.localizedString(for: d, relativeTo: Date()) 139 } 140 141 private func shortAuthor(_ s: String) -> String { 142 let n = s.count 143 if n <= 12 { return s } 144 let prefix = s.prefix(6) 145 let suffix = s.suffix(6) 146 return "\(prefix)…\(suffix)" 147 } 148 } 149 150 151 fileprivate struct InlineReplyComposer: View { 152 @Binding var text: String 153 let isSending: Bool 154 let onCancel: () -> Void 155 let onSend: () -> Void 156 @FocusState private var focused: Bool 157 158 var body: some View { 159 VStack(alignment: .leading, spacing: 10) { 160 ZStack(alignment: .topLeading) { 161 TextEditor(text: $text) 162 .font(.body) 163 .padding(10) 164 .frame(minHeight: 160, maxHeight: 220, alignment: .topLeading) 165 .scrollContentBackground(.hidden) 166 .background(Color.secondary.opacity(0.12)) 167 .clipShape(RoundedRectangle(cornerRadius: 12)) 168 .focused($focused) 169 170 if text.isEmpty { 171 Text("Write a reply") 172 .foregroundStyle(.secondary) 173 .padding(.top, 16) 174 .padding(.leading, 16) 175 .allowsHitTesting(false) 176 } 177 } 178 179 HStack { 180 Spacer() 181 Button("Cancel", role: .cancel) { onCancel() } 182 .buttonStyle(.bordered) 183 Button { 184 onSend() 185 } label: { 186 if isSending { 187 ProgressView() 188 } else { 189 Text("Send").fontWeight(.semibold) 190 } 191 } 192 .buttonStyle(.borderedProminent) 193 .disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending) 194 } 195 } 196 .onAppear { focused = true } 197 } 198 } 199 200 fileprivate struct AvatarView: View { 201 let seed: String 202 203 var body: some View { 204 ZStack { 205 Circle().fill(gradient) 206 Text(initials) 207 .font(.caption.weight(.bold)) 208 .foregroundStyle(.white.opacity(0.9)) 209 } 210 .accessibilityHidden(true) 211 } 212 213 private var initials: String { 214 let trimmed = seed.replacingOccurrences(of: "npub1", with: "") 215 return trimmed.first.map { String($0).uppercased() } ?? "?" 216 } 217 218 private var gradient: LinearGradient { 219 let hash = abs(seed.hashValue) 220 let hue = Double(hash % 360) / 360.0 221 let c1 = Color(hue: hue, saturation: 0.65, brightness: 0.85) 222 let c2 = Color(hue: (hue + 0.08).truncatingRemainder(dividingBy: 1), 223 saturation: 0.55, 224 brightness: 0.75) 225 return LinearGradient( 226 gradient: Gradient(colors: [c1, c2]), 227 startPoint: .topLeading, 228 endPoint: .bottomTrailing 229 ) 230 } 231 }