field_ios

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

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 }