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 }