Add multiple reaction support

Changelog-Added: Add support for multiple reactions
Closes: https://github.com/damus-io/damus/issues/1335
This commit is contained in:
Suhail Saqan
2023-07-29 09:42:59 -07:00
committed by William Casarin
parent 815f4d4a96
commit d11cd76e6a
8 changed files with 459 additions and 27 deletions

View File

@@ -68,11 +68,11 @@ struct EventActionBar: View {
Spacer()
HStack(spacing: 4) {
LikeButton(liked: bar.liked) {
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil) { emoji in
if bar.liked {
notify(.delete, bar.our_like)
} else {
send_like()
send_like(emoji: emoji)
}
}
@@ -139,9 +139,9 @@ struct EventActionBar: View {
}
}
func send_like() {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
return
}
@@ -166,30 +166,66 @@ func EventActionButton(img: String, col: Color?, action: @escaping () -> ()) ->
}
struct LikeButton: View {
let damus_state: DamusState
let liked: Bool
let action: () -> ()
let liked_emoji: String?
let action: (_ emoji: String) -> Void
// For reactions background
@State private var showReactionsBG = 0
@State private var showEmojis: [Int] = []
@State private var rotateThumb = -45
@State private var isReactionsVisible = false
// Following four are Shaka animation properties
let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
@State private var shouldAnimate = false
@State private var rotationAngle = 0.0
@State private var amountOfAngleIncrease: Double = 0.0
var body: some View {
var emojis: [String] {
damus_state.settings.emoji_reactions
}
@ViewBuilder
func buildMaskView(for emoji: String) -> some View {
if emoji == "🤙" {
LINEAR_GRADIENT
.mask(
Image("shaka.fill")
.resizable()
.aspectRatio(contentMode: .fit)
)
} else {
Text(emoji)
}
}
var body: some View {
ZStack {
likeButton()
.accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
.rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
.onReceive(self.timer) { _ in
shakaAnimationLogic()
}
.simultaneousGesture(longPressGesture())
.overlay(reactionsOverlay())
}
}
func likeButton() -> some View {
Button(action: {
guard !isReactionsVisible else { return }
withAnimation(Animation.easeOut(duration: 0.15)) {
self.action()
self.action(damus_state.settings.default_emoji_reaction)
shouldAnimate = true
amountOfAngleIncrease = 20.0
}
}) {
if liked {
LINEAR_GRADIENT
.mask(Image("shaka.fill")
.resizable()
.aspectRatio(contentMode: .fit)
)
if let liked_emoji {
buildMaskView(for: liked_emoji)
.frame(width: 20, height: 20)
} else {
Image("shaka")
@@ -199,23 +235,112 @@ struct LikeButton: View {
.foregroundColor(.gray)
}
}
.accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
.rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
.onReceive(self.timer) { _ in
// Shaka animation logic
rotationAngle = amountOfAngleIncrease
if amountOfAngleIncrease == 0 {
timer.upstream.connect().cancel()
return
}
amountOfAngleIncrease = -amountOfAngleIncrease
if amountOfAngleIncrease < 0 {
amountOfAngleIncrease += 2.5
}
func shakaAnimationLogic() {
rotationAngle = amountOfAngleIncrease
if amountOfAngleIncrease == 0 {
timer.upstream.connect().cancel()
return
}
amountOfAngleIncrease = -amountOfAngleIncrease
if amountOfAngleIncrease < 0 {
amountOfAngleIncrease += 2.5
} else {
amountOfAngleIncrease -= 2.5
}
}
func longPressGesture() -> some Gesture {
LongPressGesture(minimumDuration: 0.5).onEnded { _ in
reactionLongPressed()
}
}
func reactionsOverlay() -> some View {
Group {
if isReactionsVisible {
ZStack {
RoundedRectangle(cornerRadius: 10)
.frame(width: 250, height: 50)
.foregroundColor(DamusColors.black)
.scaleEffect(Double(showReactionsBG), anchor: .topTrailing)
.animation(
.interpolatingSpring(stiffness: 170, damping: 15).delay(0.05),
value: showReactionsBG
)
.overlay(
Rectangle()
.foregroundColor(Color.white.opacity(0.2))
.frame(width: 250, height: 50)
.clipShape(
RoundedRectangle(cornerRadius: 10)
)
)
.overlay(reactions())
}
.offset(y: -40)
.onTapGesture {
withAnimation(.easeOut(duration: 0.2)) {
isReactionsVisible = false
showReactionsBG = 0
}
showEmojis = []
}
} else {
amountOfAngleIncrease -= 2.5
EmptyView()
}
}
}
func reactions() -> some View {
HStack {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
ForEach(emojis, id: \.self) { emoji in
if let index = emojis.firstIndex(of: emoji) {
let scale = showEmojis.count >= index + 1 ? showEmojis[index] : 0
Text(emoji)
.scaleEffect(Double(scale))
.onTapGesture {
emojiTapped(emoji)
}
}
}
}
.padding(.horizontal, 20)
}
}
}
// When reaction button is long pressed, it displays the multiple emojis overlay and displays the user's selected emojis with an animation
private func reactionLongPressed() {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
showEmojis = Array(repeating: 0, count: emojis.count) // Initialize the showEmojis array
for (index, _) in emojis.enumerated() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1 * Double(index)) {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 8)) {
showEmojis[index] = 1
}
}
}
isReactionsVisible = true
showReactionsBG = 1
}
private func emojiTapped(_ emoji: String) {
print("Tapped emoji: \(emoji)")
self.action(emoji)
withAnimation(.easeOut(duration: 0.2)) {
isReactionsVisible = false
showReactionsBG = 0
}
showEmojis = []
}
}

View File

@@ -63,6 +63,10 @@ struct ConfigView: View {
NavigationLink(value: Route.DeveloperSettings(settings: settings)) {
IconLabel(NSLocalizedString("Developer", comment: "Section header for developer settings"), img_name: "magic-stick2.fill", color: DamusColors.adaptableBlack)
}
NavigationLink(value: Route.ReactionsSettings(settings: settings)) {
IconLabel(NSLocalizedString("Reactions", comment: "Section header for reactions settings"), img_name: "shaka.fill", color: .purple)
}
}
Section(NSLocalizedString("Sign Out", comment: "Section title for signing out")) {

View File

@@ -0,0 +1,48 @@
//
// AddEmojiView.swift
// damus
//
// Created by Suhail Saqan on 7/16/23.
//
import SwiftUI
struct AddEmojiView: View {
@Binding var emoji: String
var body: some View {
ZStack(alignment: .leading) {
HStack{
TextField(NSLocalizedString("", comment: "Placeholder example for an emoji reaction"), text: $emoji)
.padding(2)
.padding(.leading, 25)
.opacity(emoji == "" ? 0.5 : 1)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onChange(of: emoji) { newEmoji in
if let lastEmoji = newEmoji.last.map(String.init), isValidEmoji(lastEmoji) {
self.emoji = lastEmoji
} else {
self.emoji = ""
}
}
Label("", image: "close-circle")
.foregroundColor(.accentColor)
.padding(.trailing, -25.0)
.opacity((emoji == "") ? 0.0 : 1.0)
.onTapGesture {
self.emoji = ""
}
}
Label("", image: "copy2")
.padding(.leading, -10)
.onTapGesture {
if let pastedEmoji = UIPasteboard.general.string {
self.emoji = pastedEmoji
}
}
}
}
}

View File

@@ -0,0 +1,79 @@
//
// EmojiListItemView.swift
// damus
//
// Created by Suhail Saqan on 7/16/23.
//
import SwiftUI
struct EmojiListItemView: View {
@ObservedObject var settings: UserSettingsStore
let emoji: String
let recommended: Bool
@Binding var showActionButtons: Bool
var body: some View {
Group {
HStack {
if showActionButtons {
if recommended {
AddButton()
} else {
RemoveButton()
}
}
Text(emoji)
}
}
.swipeActions {
if !recommended {
RemoveButton()
.tint(.red)
} else {
AddButton()
.tint(.green)
}
}
.contextMenu {
CopyAction(emoji: emoji)
}
}
func CopyAction(emoji: String) -> some View {
Button {
UIPasteboard.general.setValue(emoji, forPasteboardType: "public.plain-text")
} label: {
Label(NSLocalizedString("Copy", comment: "Button to copy an emoji reaction"), image: "copy2")
}
}
func RemoveButton() -> some View {
Button(action: {
if let index = settings.emoji_reactions.firstIndex(of: emoji) {
settings.emoji_reactions.remove(at: index)
}
}) {
Image(systemName: "minus.circle")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.red)
.padding(.leading, 5)
}
}
func AddButton() -> some View {
Button(action: {
settings.emoji_reactions.append(emoji)
}) {
Image(systemName: "plus.circle")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.green)
.padding(.leading, 5)
}
}
}

View File

@@ -0,0 +1,151 @@
//
// ReactionsSettingsView.swift
// damus
//
// Created by Suhail Saqan on 7/3/23.
//
import SwiftUI
import Combine
let default_emoji_reactions = ["🤣", "🤙", "", "💜", "🔥", "😀", "😃", "😄", "🥶"]
struct ReactionsSettingsView: View {
@ObservedObject var settings: UserSettingsStore
@State var new_emoji: String = ""
@State private var showActionButtons = false
@Environment(\.dismiss) var dismiss
var recommended: [String] {
return getMissingRecommendedEmojis(added: settings.emoji_reactions)
}
var body: some View {
Form {
Section {
AddEmojiView(emoji: $new_emoji)
} header: {
Text(NSLocalizedString("Add Emoji", comment: "Label for section for adding an emoji to the reactions list."))
.font(.system(size: 18, weight: .heavy))
.padding(.bottom, 5)
} footer: {
HStack {
Spacer()
if !new_emoji.isEmpty {
Button(NSLocalizedString("Cancel", comment: "Button to cancel out of view adding user inputted emoji.")) {
new_emoji = ""
}
.font(.system(size: 14, weight: .bold))
.frame(width: 80, height: 30)
.foregroundColor(.white)
.background(LINEAR_GRADIENT)
.clipShape(Capsule())
.padding(EdgeInsets(top: 15, leading: 0, bottom: 0, trailing: 0))
Button(NSLocalizedString("Add", comment: "Button to confirm adding user inputted emoji.")) {
if isValidEmoji(new_emoji) {
settings.emoji_reactions.append(new_emoji)
new_emoji = ""
}
}
.font(.system(size: 14, weight: .bold))
.frame(width: 80, height: 30)
.foregroundColor(.white)
.background(LINEAR_GRADIENT)
.clipShape(Capsule())
.padding(EdgeInsets(top: 15, leading: 0, bottom: 0, trailing: 0))
}
}
}
Picker(NSLocalizedString("Select default emoji", comment: "Prompt selection of user's default emoji reaction"),
selection: $settings.default_emoji_reaction) {
ForEach(settings.emoji_reactions, id: \.self) { emoji in
Text(emoji)
}
}
Section {
List(settings.emoji_reactions, id: \.self) { emoji in
EmojiListItemView(settings: settings, emoji: emoji, recommended: false, showActionButtons: $showActionButtons)
}
} header: {
Text("Emoji Reactions", comment: "Section title for emoji reactions that are currently added.")
.font(.system(size: 18, weight: .heavy))
.padding(.bottom, 5)
}
if recommended.count > 0 {
Section {
List(Array(recommended), id: \.self) { emoji in
EmojiListItemView(settings: settings, emoji: emoji, recommended: true, showActionButtons: $showActionButtons)
}
} header: {
Text("Recommended Emojis", comment: "Section title for recommend emojis")
.font(.system(size: 18, weight: .heavy))
.padding(.bottom, 5)
}
}
}
.navigationTitle(NSLocalizedString("Reactions", comment: "Title of emoji reactions view"))
.navigationBarTitleDisplayMode(.large)
.toolbar {
if showActionButtons {
Button("Done") {
showActionButtons.toggle()
}
} else {
Button("Edit") {
showActionButtons.toggle()
}
}
}
}
// Returns the emojis that are in the recommended list but the user has not added yet
func getMissingRecommendedEmojis(added: [String], recommended: [String] = default_emoji_reactions) -> [String] {
let addedSet = Set(added)
let missingEmojis = recommended.filter { !addedSet.contains($0) }
return missingEmojis
}
}
/// From: https://stackoverflow.com/a/39425959
extension Character {
/// A simple emoji is one scalar and presented to the user as an Emoji
var isSimpleEmoji: Bool {
guard let firstScalar = unicodeScalars.first else { return false }
return firstScalar.properties.isEmoji && firstScalar.value > 0x238C
}
/// Checks if the scalars will be merged into an emoji
var isCombinedIntoEmoji: Bool { unicodeScalars.count > 1 && unicodeScalars.first?.properties.isEmoji ?? false }
var isEmoji: Bool { isSimpleEmoji || isCombinedIntoEmoji }
}
extension String {
var isSingleEmoji: Bool { count == 1 && containsEmoji }
var containsEmoji: Bool { contains { $0.isEmoji } }
var containsOnlyEmoji: Bool { !isEmpty && !contains { !$0.isEmoji } }
var emojiString: String { emojis.map { String($0) }.reduce("", +) }
var emojis: [Character] { filter { $0.isEmoji } }
var emojiScalars: [UnicodeScalar] { filter { $0.isEmoji }.flatMap { $0.unicodeScalars } }
}
func isValidEmoji(_ string: String) -> Bool {
return string.isSingleEmoji
}
struct ReactionsSettingsView_Previews: PreviewProvider {
static var previews: some View {
ReactionsSettingsView(settings: UserSettingsStore())
}
}