damus

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

EventActionBar.swift (17371B)


      1 //
      2 //  EventActionBar.swift
      3 //  damus
      4 //
      5 //  Created by William Casarin on 2022-04-16.
      6 //
      7 
      8 import SwiftUI
      9 import EmojiPicker
     10 import EmojiKit
     11 import SwipeActions
     12 
     13 struct EventActionBar: View {
     14     let damus_state: DamusState
     15     let event: NostrEvent
     16     let generator = UIImpactFeedbackGenerator(style: .medium)
     17     let userProfile : ProfileModel
     18     let swipe_context: SwipeContext?
     19     let options: Options
     20     
     21     // just used for previews
     22     @State var show_share_sheet: Bool = false
     23     @State var show_share_action: Bool = false
     24     @State var show_repost_action: Bool = false
     25 
     26     @State private var selectedEmoji: Emoji? = nil
     27 
     28     @ObservedObject var bar: ActionBarModel
     29     
     30     init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil, options: Options = [], swipe_context: SwipeContext? = nil) {
     31         self.damus_state = damus_state
     32         self.event = event
     33         _bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
     34         self.userProfile = ProfileModel(pubkey: event.pubkey, damus: damus_state)
     35         self.options = options
     36         self.swipe_context = swipe_context
     37     }
     38     
     39     var lnurl: String? {
     40         damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
     41             pr?.lnurl
     42         }).value
     43     }
     44     
     45     var show_like: Bool {
     46         if damus_state.settings.onlyzaps_mode {
     47             return false
     48         }
     49         
     50         return true
     51     }
     52     
     53     var space_if_spread: AnyView {
     54         if options.contains(.no_spread) {
     55             return AnyView(EmptyView())
     56         }
     57         else {
     58             return AnyView(Spacer())
     59         }
     60     }
     61     
     62     // MARK: Swipe action menu buttons
     63     
     64     var reply_swipe_button: some View {
     65         SwipeAction(systemImage: "arrowshape.turn.up.left.fill", backgroundColor: DamusColors.adaptableGrey) {
     66             notify(.compose(.replying_to(event)))
     67             self.swipe_context?.state.wrappedValue = .closed
     68         }
     69         .allowSwipeToTrigger()
     70         .swipeButtonStyle()
     71         .accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
     72     }
     73     
     74     var repost_swipe_button: some View {
     75         SwipeAction(image: "repost", backgroundColor: DamusColors.adaptableGrey) {
     76             self.show_repost_action = true
     77             self.swipe_context?.state.wrappedValue = .closed
     78         }
     79         .swipeButtonStyle()
     80         .accessibilityLabel(NSLocalizedString("Repost or quote this note", comment: "Accessibility label for repost/quote button"))
     81     }
     82     
     83     var like_swipe_button: some View {
     84         SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) {
     85             send_like(emoji: damus_state.settings.default_emoji_reaction)
     86             self.swipe_context?.state.wrappedValue = .closed
     87         }
     88         .swipeButtonStyle()
     89         .accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button"))
     90     }
     91     
     92     var share_swipe_button: some View {
     93         SwipeAction(image: "upload", backgroundColor: DamusColors.adaptableGrey) {
     94             show_share_action = true
     95             self.swipe_context?.state.wrappedValue = .closed
     96         }
     97         .swipeButtonStyle()
     98         .accessibilityLabel(NSLocalizedString("Share externally", comment: "Accessibility label for external share button"))
     99     }
    100     
    101     // MARK: Bar buttons
    102     
    103     var reply_button: some View {
    104         HStack(spacing: 4) {
    105             EventActionButton(img: "bubble2", col: bar.replied ? DamusColors.purple : Color.gray) {
    106                 notify(.compose(.replying_to(event)))
    107             }
    108             .accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
    109             Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
    110                 .font(.footnote.weight(.medium))
    111                 .foregroundColor(bar.replied ? DamusColors.purple : Color.gray)
    112         }
    113     }
    114     
    115     var repost_button: some View {
    116         HStack(spacing: 4) {
    117             
    118             EventActionButton(img: "repost", col: bar.boosted ? Color.green : nil) {
    119                 self.show_repost_action = true
    120             }
    121             .accessibilityLabel(NSLocalizedString("Reposts", comment: "Accessibility label for boosts button"))
    122             Text(verbatim: "\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
    123                 .font(.footnote.weight(.medium))
    124                 .foregroundColor(bar.boosted ? Color.green : Color.gray)
    125         }
    126     }
    127     
    128     var like_button: some View {
    129         HStack(spacing: 4) {
    130             LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil) { emoji in
    131                 if bar.liked {
    132                     //notify(.delete, bar.our_like)
    133                 } else {
    134                     send_like(emoji: emoji)
    135                 }
    136             }
    137             
    138             Text(verbatim: "\(bar.likes > 0 ? "\(bar.likes)" : "")")
    139                 .font(.footnote.weight(.medium))
    140                 .nip05_colorized(gradient: bar.liked)
    141         }
    142     }
    143     
    144     var share_button: some View {
    145         EventActionButton(img: "upload", col: Color.gray) {
    146             show_share_action = true
    147         }
    148         .accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a note"))
    149     }
    150     
    151     // MARK: Main views
    152     
    153     var swipe_action_menu_content: some View {
    154         Group {
    155             self.reply_swipe_button
    156             self.repost_swipe_button
    157             if show_like {
    158                 self.like_swipe_button
    159             }
    160         }
    161     }
    162     
    163     var swipe_action_menu_reverse_content: some View {
    164         Group {
    165             if show_like {
    166                 self.like_swipe_button
    167             }
    168             self.repost_swipe_button
    169             self.reply_swipe_button
    170         }
    171     }
    172     
    173     var action_bar_content: some View {
    174         let hide_items_without_activity = options.contains(.hide_items_without_activity)
    175         let should_hide_chat_bubble = hide_items_without_activity && bar.replies == 0
    176         let should_hide_repost = hide_items_without_activity && bar.boosts == 0
    177         let should_hide_reactions = hide_items_without_activity && bar.likes == 0
    178         let zap_model = self.damus_state.events.get_cache_data(self.event.id).zaps_model
    179         let should_hide_zap = hide_items_without_activity && zap_model.zap_total > 0
    180         let should_hide_share_button = hide_items_without_activity
    181 
    182         return HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
    183             if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
    184                 self.reply_button
    185             }
    186             
    187             if !should_hide_repost {
    188                 self.space_if_spread
    189                 self.repost_button
    190             }
    191             
    192             if show_like && !should_hide_reactions {
    193                 self.space_if_spread
    194                 self.like_button
    195             }
    196                 
    197             if let lnurl = self.lnurl, !should_hide_zap {
    198                 self.space_if_spread
    199                 NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
    200             }
    201             
    202             if !should_hide_share_button {
    203                 self.space_if_spread
    204                 self.share_button
    205             }
    206         }
    207     }
    208     
    209     var content: some View {
    210         if options.contains(.swipe_action_menu) {
    211             AnyView(self.swipe_action_menu_content)
    212         }
    213         else if options.contains(.swipe_action_menu_reverse) {
    214             AnyView(self.swipe_action_menu_reverse_content)
    215         }
    216         else {
    217             AnyView(self.action_bar_content)
    218         }
    219     }
    220     
    221     var body: some View {
    222         self.content
    223         .onAppear {
    224             self.bar.update(damus: damus_state, evid: self.event.id)
    225         }
    226         .sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) {
    227             if #available(iOS 16.0, *) {
    228                 ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share: $show_share_sheet, userProfile: userProfile)
    229                     .presentationDetents([.height(300)])
    230                     .presentationDragIndicator(.visible)
    231             } else {
    232                 ShareAction(event: event, bookmarks: damus_state.bookmarks, show_share: $show_share_sheet, userProfile: userProfile)
    233             }
    234         }
    235         .sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
    236             ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
    237         }
    238         .sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
    239         
    240             if #available(iOS 16.0, *) {
    241                 RepostAction(damus_state: self.damus_state, event: event)
    242                     .presentationDetents([.height(220)])
    243                     .presentationDragIndicator(.visible)
    244             } else {
    245                 RepostAction(damus_state: self.damus_state, event: event)
    246             }
    247         }
    248         .onReceive(handle_notify(.update_stats)) { target in
    249             guard target == self.event.id else { return }
    250             self.bar.update(damus: self.damus_state, evid: target)
    251         }
    252         .onReceive(handle_notify(.liked)) { liked in
    253             if liked.id != event.id {
    254                 return
    255             }
    256             self.bar.likes = liked.total
    257             if liked.event.pubkey == damus_state.keypair.pubkey {
    258                 self.bar.our_like = liked.event
    259             }
    260         }
    261     }
    262 
    263     func send_like(emoji: String) {
    264         guard let keypair = damus_state.keypair.to_full(),
    265               let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
    266             return
    267         }
    268 
    269         self.bar.our_like = like_ev
    270 
    271         generator.impactOccurred()
    272         
    273         damus_state.postbox.send(like_ev)
    274     }
    275     
    276     // MARK: Helper structures
    277     
    278     struct Options: OptionSet {
    279         let rawValue: UInt32
    280         
    281         static let no_spread = Options(rawValue: 1 << 0)
    282         static let hide_items_without_activity = Options(rawValue: 1 << 1)
    283         static let swipe_action_menu = Options(rawValue: 1 << 2)
    284         static let swipe_action_menu_reverse = Options(rawValue: 1 << 3)
    285     }
    286 }
    287 
    288 
    289 func EventActionButton(img: String, col: Color?, action: @escaping () -> ()) -> some View {
    290     Image(img)
    291         .resizable()
    292         .foregroundColor(col == nil ? Color.gray : col!)
    293         .font(.footnote.weight(.medium))
    294         .aspectRatio(contentMode: .fit)
    295         .frame(width: 20, height: 20)
    296         .onTapGesture {
    297             action()
    298         }
    299 }
    300 
    301 struct LikeButton: View {
    302     let damus_state: DamusState
    303     let liked: Bool
    304     let liked_emoji: String?
    305     let action: (_ emoji: String) -> Void
    306 
    307     // For reactions background
    308     @State private var showReactionsBG = 0
    309     @State private var rotateThumb = -45
    310 
    311     @State private var isReactionsVisible = false
    312 
    313     @State private var selectedEmoji: Emoji?
    314 
    315     // Following four are Shaka animation properties
    316     let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
    317     @State private var shouldAnimate = false
    318     @State private var rotationAngle = 0.0
    319     @State private var amountOfAngleIncrease: Double = 0.0
    320 
    321     var emojis: [String] {
    322         damus_state.settings.emoji_reactions
    323     }
    324     
    325     @ViewBuilder
    326     func buildMaskView(for emoji: String) -> some View {
    327         if emoji == "🤙" {
    328             LINEAR_GRADIENT
    329                 .mask(
    330                     Image("shaka.fill")
    331                         .resizable()
    332                         .aspectRatio(contentMode: .fit)
    333                 )
    334         } else {
    335             Text(emoji)
    336         }
    337     }
    338 
    339     var body: some View {
    340         Group {
    341             if let liked_emoji {
    342                 buildMaskView(for: liked_emoji)
    343                     .frame(width: 22, height: 20)
    344             } else {
    345                 Image("shaka")
    346                     .resizable()
    347                     .aspectRatio(contentMode: .fit)
    348                     .frame(width: 22, height: 20)
    349                     .foregroundColor(.gray)
    350             }
    351         }
    352         .sheet(isPresented: $isReactionsVisible) {
    353             NavigationView {
    354                 EmojiPickerView(selectedEmoji: $selectedEmoji, emojiProvider: damus_state.emoji_provider)
    355             }.presentationDetents([.medium, .large])
    356         }
    357         .accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
    358         .rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
    359         .onReceive(self.timer) { _ in
    360             shakaAnimationLogic()
    361         }
    362         .simultaneousGesture(longPressGesture())
    363         .highPriorityGesture(TapGesture().onEnded {
    364             guard !isReactionsVisible else { return }
    365             withAnimation(Animation.easeOut(duration: 0.15)) {
    366                 self.action(damus_state.settings.default_emoji_reaction)
    367                 shouldAnimate = true
    368                 amountOfAngleIncrease = 20.0
    369             }
    370         })
    371         .onChange(of: selectedEmoji) { newSelectedEmoji in
    372             if let newSelectedEmoji {
    373                 self.action(newSelectedEmoji.value)
    374             }
    375         }
    376     }
    377 
    378     func shakaAnimationLogic() {
    379         rotationAngle = amountOfAngleIncrease
    380         if amountOfAngleIncrease == 0 {
    381             timer.upstream.connect().cancel()
    382             return
    383         }
    384         amountOfAngleIncrease = -amountOfAngleIncrease
    385         if amountOfAngleIncrease < 0 {
    386             amountOfAngleIncrease += 2.5
    387         } else {
    388             amountOfAngleIncrease -= 2.5
    389         }
    390     }
    391 
    392     func longPressGesture() -> some Gesture {
    393         LongPressGesture(minimumDuration: 0.5).onEnded { _ in
    394             reactionLongPressed()
    395         }
    396     }
    397 
    398     // When reaction button is long pressed, it displays the multiple emojis overlay and displays the user's selected emojis with an animation
    399     private func reactionLongPressed() {
    400         UIImpactFeedbackGenerator(style: .medium).impactOccurred()
    401         
    402         isReactionsVisible = true
    403     }
    404     
    405     private func emojiTapped(_ emoji: String) {
    406         print("Tapped emoji: \(emoji)")
    407         
    408         self.action(emoji)
    409 
    410         withAnimation(.easeOut(duration: 0.2)) {
    411             isReactionsVisible = false
    412         }
    413         
    414         withAnimation(Animation.easeOut(duration: 0.15)) {
    415             shouldAnimate = true
    416             amountOfAngleIncrease = 20.0
    417         }
    418     }
    419 }
    420 
    421 struct EventActionBar_Previews: PreviewProvider {
    422     static var previews: some View {
    423         let ds = test_damus_state
    424         let ev = NostrEvent(content: "hi", keypair: test_keypair)!
    425 
    426         let bar = ActionBarModel.empty()
    427         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)
    428         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)
    429         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)
    430         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)
    431         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)
    432 
    433         VStack(spacing: 50) {
    434             EventActionBar(damus_state: ds, event: ev, bar: bar)
    435             
    436             EventActionBar(damus_state: ds, event: ev, bar: likedbar)
    437             
    438             EventActionBar(damus_state: ds, event: ev, bar: likedbar_ours)
    439             
    440             EventActionBar(damus_state: ds, event: ev, bar: maxed_bar)
    441             
    442             EventActionBar(damus_state: ds, event: ev, bar: extra_max_bar)
    443 
    444             EventActionBar(damus_state: ds, event: ev, bar: mega_max_bar)
    445             
    446             EventActionBar(damus_state: ds, event: ev, bar: bar, options: [.no_spread])
    447         }
    448         .padding(20)
    449     }
    450 }
    451 
    452 // MARK: Helpers
    453 
    454 fileprivate struct SwipeButtonStyle: ViewModifier {
    455     func body(content: Content) -> some View {
    456         content
    457             .frame(width: 50, height: 50)
    458             .clipShape(Circle())
    459             .overlay(Circle().stroke(Color.damusAdaptableGrey2, lineWidth: 2))
    460     }
    461 }
    462 
    463 fileprivate extension View {
    464     func swipeButtonStyle() -> some View {
    465         modifier(SwipeButtonStyle())
    466     }
    467 }
    468 
    469 // MARK: Needed extensions for SwipeAction
    470 
    471 public extension SwipeAction where Label == Image, Background == Color {
    472     init(
    473         image: String,
    474         backgroundColor: Color = Color.primary.opacity(0.1),
    475         highlightOpacity: Double = 0.5,
    476         action: @escaping () -> Void
    477     ) {
    478         self.init(action: action) { highlight in
    479             Image(image)
    480         } background: { highlight in
    481             backgroundColor
    482                 .opacity(highlight ? highlightOpacity : 1)
    483         }
    484     }
    485 }