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 }