damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

EventActionBar.swift (15285B)


      1 //
      2 //  EventActionBar.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2022-04-16.
      6 //
      7 
      8 import SwiftUI
      9 import UIKit
     10 
     11 
     12 struct EventActionBar: View {
     13     let damus_state: DamusState
     14     let event: NostrEvent
     15     let generator = UIImpactFeedbackGenerator(style: .medium)
     16     let userProfile : ProfileModel
     17     
     18     // just used for previews
     19     @State var show_share_sheet: Bool = false
     20     @State var show_share_action: Bool = false
     21     @State var show_repost_action: Bool = false
     22 
     23     @ObservedObject var bar: ActionBarModel
     24     
     25     init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil) {
     26         self.damus_state = damus_state
     27         self.event = event
     28         _bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
     29         self.userProfile = ProfileModel(pubkey: event.pubkey, damus: damus_state)
     30     }
     31     
     32     var lnurl: String? {
     33         damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
     34             pr?.lnurl
     35         }).value
     36     }
     37     
     38     var show_like: Bool {
     39         if damus_state.settings.onlyzaps_mode {
     40             return false
     41         }
     42         
     43         return true
     44     }
     45     
     46     var body: some View {
     47         HStack {
     48             if damus_state.keypair.privkey != nil {
     49                 HStack(spacing: 4) {
     50                     EventActionButton(img: "bubble2", col: bar.replied ? DamusColors.purple : Color.gray) {
     51                         notify(.compose(.replying_to(event)))
     52                     }
     53                     .accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
     54                     Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
     55                         .font(.footnote.weight(.medium))
     56                         .foregroundColor(bar.replied ? DamusColors.purple : Color.gray)
     57                 }
     58             }
     59             Spacer()
     60             HStack(spacing: 4) {
     61                 
     62                 EventActionButton(img: "repost", col: bar.boosted ? Color.green : nil) {
     63                     self.show_repost_action = true
     64                 }
     65                 .accessibilityLabel(NSLocalizedString("Reposts", comment: "Accessibility label for boosts button"))
     66                 Text(verbatim: "\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
     67                     .font(.footnote.weight(.medium))
     68                     .foregroundColor(bar.boosted ? Color.green : Color.gray)
     69             }
     70 
     71             if show_like {
     72                 Spacer()
     73 
     74                 HStack(spacing: 4) {
     75                     LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil) { emoji in
     76                         if bar.liked {
     77                             //notify(.delete, bar.our_like)
     78                         } else {
     79                             send_like(emoji: emoji)
     80                         }
     81                     }
     82 
     83                     Text(verbatim: "\(bar.likes > 0 ? "\(bar.likes)" : "")")
     84                         .font(.footnote.weight(.medium))
     85                         .nip05_colorized(gradient: bar.liked)
     86                 }
     87             }
     88 
     89             if let lnurl = self.lnurl {
     90                 Spacer()
     91                 NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model)
     92             }
     93 
     94             Spacer()
     95             EventActionButton(img: "upload", col: Color.gray) {
     96                 show_share_action = true
     97             }
     98             .accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a note"))
     99         }
    100         .onAppear {
    101             self.bar.update(damus: damus_state, evid: self.event.id)
    102         }
    103         .sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) {
    104             if #available(iOS 16.0, *) {
    105                 ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share: $show_share_sheet, userProfile: userProfile)
    106                     .presentationDetents([.height(300)])
    107                     .presentationDragIndicator(.visible)
    108             } else {
    109                 ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share: $show_share_sheet, userProfile: userProfile)
    110             }
    111         }
    112         .sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
    113             ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
    114         }
    115         .sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
    116         
    117             if #available(iOS 16.0, *) {
    118                 RepostAction(damus_state: self.damus_state, event: event)
    119                     .presentationDetents([.height(220)])
    120                     .presentationDragIndicator(.visible)
    121             } else {
    122                 RepostAction(damus_state: self.damus_state, event: event)
    123             }
    124         }
    125         .onReceive(handle_notify(.update_stats)) { target in
    126             guard target == self.event.id else { return }
    127             self.bar.update(damus: self.damus_state, evid: target)
    128         }
    129         .onReceive(handle_notify(.liked)) { liked in
    130             if liked.id != event.id {
    131                 return
    132             }
    133             self.bar.likes = liked.total
    134             if liked.event.pubkey == damus_state.keypair.pubkey {
    135                 self.bar.our_like = liked.event
    136             }
    137         }
    138     }
    139     
    140     func send_like(emoji: String) {
    141         guard let keypair = damus_state.keypair.to_full(),
    142               let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
    143             return
    144         }
    145 
    146         self.bar.our_like = like_ev
    147 
    148         generator.impactOccurred()
    149         
    150         damus_state.postbox.send(like_ev)
    151     }
    152 }
    153 
    154 
    155 func EventActionButton(img: String, col: Color?, action: @escaping () -> ()) -> some View {
    156     Image(img)
    157         .resizable()
    158         .foregroundColor(col == nil ? Color.gray : col!)
    159         .font(.footnote.weight(.medium))
    160         .aspectRatio(contentMode: .fit)
    161         .frame(width: 20, height: 20)
    162         .onTapGesture {
    163             action()
    164         }
    165 }
    166 
    167 struct LikeButton: View {
    168     let damus_state: DamusState
    169     let liked: Bool
    170     let liked_emoji: String?
    171     let action: (_ emoji: String) -> Void
    172 
    173     // For reactions background
    174     @State private var showReactionsBG = 0
    175     @State private var showEmojis: [Int] = []
    176     @State private var rotateThumb = -45
    177 
    178     @State private var isReactionsVisible = false
    179 
    180     // Following four are Shaka animation properties
    181     let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
    182     @State private var shouldAnimate = false
    183     @State private var rotationAngle = 0.0
    184     @State private var amountOfAngleIncrease: Double = 0.0
    185 
    186     var emojis: [String] {
    187         damus_state.settings.emoji_reactions
    188     }
    189     
    190     @ViewBuilder
    191     func buildMaskView(for emoji: String) -> some View {
    192         if emoji == "🤙" {
    193             LINEAR_GRADIENT
    194                 .mask(
    195                     Image("shaka.fill")
    196                         .resizable()
    197                         .aspectRatio(contentMode: .fit)
    198                 )
    199         } else {
    200             Text(emoji)
    201         }
    202     }
    203 
    204     var body: some View {
    205         Group {
    206             if let liked_emoji {
    207                 buildMaskView(for: liked_emoji)
    208                     .frame(width: 20, height: 20)
    209             } else {
    210                 Image("shaka")
    211                     .resizable()
    212                     .aspectRatio(contentMode: .fit)
    213                     .frame(width: 20, height: 20)
    214                     .foregroundColor(.gray)
    215             }
    216         }
    217         .accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
    218         .rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
    219         .onReceive(self.timer) { _ in
    220             shakaAnimationLogic()
    221         }
    222         .simultaneousGesture(longPressGesture())
    223         .highPriorityGesture(TapGesture().onEnded {
    224             guard !isReactionsVisible else { return }
    225             withAnimation(Animation.easeOut(duration: 0.15)) {
    226                 self.action(damus_state.settings.default_emoji_reaction)
    227                 shouldAnimate = true
    228                 amountOfAngleIncrease = 20.0
    229             }
    230         })
    231         .overlay(reactionsOverlay())
    232     }
    233 
    234     func shakaAnimationLogic() {
    235         rotationAngle = amountOfAngleIncrease
    236         if amountOfAngleIncrease == 0 {
    237             timer.upstream.connect().cancel()
    238             return
    239         }
    240         amountOfAngleIncrease = -amountOfAngleIncrease
    241         if amountOfAngleIncrease < 0 {
    242             amountOfAngleIncrease += 2.5
    243         } else {
    244             amountOfAngleIncrease -= 2.5
    245         }
    246     }
    247 
    248     func longPressGesture() -> some Gesture {
    249         LongPressGesture(minimumDuration: 0.5).onEnded { _ in
    250             reactionLongPressed()
    251         }
    252     }
    253 
    254     func reactionsOverlay() -> some View {
    255         Group {
    256             if isReactionsVisible {
    257                 ZStack {
    258                     RoundedRectangle(cornerRadius: 20)
    259                         .frame(width: calculateOverlayWidth(), height: 50)
    260                         .foregroundColor(DamusColors.black)
    261                         .scaleEffect(Double(showReactionsBG), anchor: .topTrailing)
    262                         .animation(
    263                             .interpolatingSpring(stiffness: 170, damping: 15).delay(0.05),
    264                             value: showReactionsBG
    265                         )
    266                         .overlay(
    267                             Rectangle()
    268                                 .foregroundColor(Color.white.opacity(0.2))
    269                                 .frame(width: calculateOverlayWidth(), height: 50)
    270                                 .clipShape(
    271                                     RoundedRectangle(cornerRadius: 20)
    272                                 )
    273                         )
    274                         .overlay(reactions())
    275                 }
    276                 .offset(y: -40)
    277                 .onTapGesture {
    278                     withAnimation(.easeOut(duration: 0.2)) {
    279                         isReactionsVisible = false
    280                         showReactionsBG = 0
    281                     }
    282                     showEmojis = []
    283                 }
    284             } else {
    285                 EmptyView()
    286             }
    287         }
    288     }
    289     
    290     func calculateOverlayWidth() -> CGFloat {
    291         let maxWidth: CGFloat = 250
    292         let numberOfEmojis = emojis.count
    293         let minimumWidth: CGFloat = 75
    294         
    295         if numberOfEmojis > 0 {
    296             let emojiWidth: CGFloat = 25
    297             let padding: CGFloat = 15
    298             let buttonWidth: CGFloat = 18
    299             let buttonPadding: CGFloat = 20
    300             
    301             let totalWidth = CGFloat(numberOfEmojis) * (emojiWidth + padding) + buttonWidth + buttonPadding
    302             return min(maxWidth, max(minimumWidth, totalWidth))
    303         } else {
    304             return minimumWidth
    305         }
    306     }
    307 
    308     func reactions() -> some View {
    309         HStack {
    310             ScrollView(.horizontal, showsIndicators: false) {
    311                 HStack(spacing: 15) {
    312                     ForEach(emojis, id: \.self) { emoji in
    313                         if let index = emojis.firstIndex(of: emoji) {
    314                             let scale = index < showEmojis.count ? showEmojis[index] : 0
    315                             Text(emoji)
    316                                 .font(.system(size: 25))
    317                                 .scaleEffect(Double(scale))
    318                                 .onTapGesture {
    319                                     emojiTapped(emoji)
    320                                 }
    321                         }
    322                     }
    323                 }
    324                 .padding(.leading, 10)
    325             }
    326             Button(action: {
    327                 withAnimation(.easeOut(duration: 0.2)) {
    328                     isReactionsVisible = false
    329                     showReactionsBG = 0
    330                 }
    331                 showEmojis = []
    332             }) {
    333                 Image(systemName: "xmark.circle.fill")
    334                     .font(.system(size: 18))
    335                     .foregroundColor(.gray)
    336             }
    337             .padding(.trailing, 7.5)
    338         }
    339     }
    340 
    341     // When reaction button is long pressed, it displays the multiple emojis overlay and displays the user's selected emojis with an animation
    342     private func reactionLongPressed() {
    343         UIImpactFeedbackGenerator(style: .medium).impactOccurred()
    344         showEmojis = Array(repeating: 0, count: emojis.count) // Initialize the showEmojis array
    345         
    346         for (index, _) in emojis.enumerated() {
    347             DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 * Double(index)) {
    348                 withAnimation(.interpolatingSpring(stiffness: 170, damping: 8)) {
    349                     if index < showEmojis.count {
    350                         showEmojis[index] = 1
    351                     }
    352                 }
    353             }
    354         }
    355         
    356         isReactionsVisible = true
    357         showReactionsBG = 1
    358     }
    359     
    360     private func emojiTapped(_ emoji: String) {
    361         print("Tapped emoji: \(emoji)")
    362         
    363         self.action(emoji)
    364 
    365         withAnimation(.easeOut(duration: 0.2)) {
    366             isReactionsVisible = false
    367             showReactionsBG = 0
    368         }
    369         showEmojis = []
    370         
    371         withAnimation(Animation.easeOut(duration: 0.15)) {
    372             shouldAnimate = true
    373             amountOfAngleIncrease = 20.0
    374         }
    375     }
    376 }
    377 
    378 
    379 struct EventActionBar_Previews: PreviewProvider {
    380     static var previews: some View {
    381         let ds = test_damus_state
    382         let ev = NostrEvent(content: "hi", keypair: test_keypair)!
    383 
    384         let bar = ActionBarModel.empty()
    385         let likedbar = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
    386         let likedbar_ours = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: test_note, our_boost: nil, our_zap: nil, our_reply: nil)
    387         let maxed_bar = ActionBarModel(likes: 999, boosts: 999, zaps: 999, zap_total: 99999999, replies: 999, our_like: test_note, our_boost: test_note, our_zap: nil, our_reply: nil)
    388         let extra_max_bar = ActionBarModel(likes: 9999, boosts: 9999, zaps: 9999, zap_total: 99999999, replies: 9999, our_like: test_note, our_boost: test_note, our_zap: nil, our_reply: test_note)
    389         let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999,  our_like: test_note, our_boost: test_note, our_zap: .zap(test_zap), our_reply: test_note)
    390 
    391         VStack(spacing: 50) {
    392             EventActionBar(damus_state: ds, event: ev, bar: bar)
    393             
    394             EventActionBar(damus_state: ds, event: ev, bar: likedbar)
    395             
    396             EventActionBar(damus_state: ds, event: ev, bar: likedbar_ours)
    397             
    398             EventActionBar(damus_state: ds, event: ev, bar: maxed_bar)
    399             
    400             EventActionBar(damus_state: ds, event: ev, bar: extra_max_bar)
    401 
    402             EventActionBar(damus_state: ds, event: ev, bar: mega_max_bar)
    403         }
    404         .padding(20)
    405     }
    406 }