damus

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

commit d11cd76e6afec428629579cf65afd119ff82cef8
parent 815f4d4a9639816fb7e0b45b5833ba4a5c06ebae
Author: Suhail Saqan <suhail.saqan@gmail.com>
Date:   Sat, 29 Jul 2023 09:42:59 -0700

Add multiple reaction support

Changelog-Added: Add support for multiple reactions
Closes: https://github.com/damus-io/damus/issues/1335

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 12++++++++++++
Mdamus/Models/UserSettingsStore.swift | 6++++++
Mdamus/Util/Router.swift | 7+++++++
Mdamus/Views/ActionBar/EventActionBar.swift | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mdamus/Views/ConfigView.swift | 4++++
Adamus/Views/Settings/AddEmojiView.swift | 48++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Settings/EmojiListItemView.swift | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Settings/ReactionsSettingsView.swift | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 457 insertions(+), 25 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F92280F66F5000448DE /* ReplyMap.swift */; }; 4C0C03992A61E27B0098B3B8 /* primal.wasm in Resources */ = {isa = PBXBuildFile; fileRef = 4C0C03972A61E27B0098B3B8 /* primal.wasm */; }; 4C0C039A2A61E27B0098B3B8 /* bool_setting.wasm in Resources */ = {isa = PBXBuildFile; fileRef = 4C0C03982A61E27B0098B3B8 /* bool_setting.wasm */; }; + 4C15C7152A55DE7A00D0A0DB /* ReactionsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */; }; 4C190F202A535FC200027FD5 /* CustomizeZapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C190F1F2A535FC200027FD5 /* CustomizeZapModel.swift */; }; 4C190F252A547D2000027FD5 /* LoadScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C190F242A547D2000027FD5 /* LoadScript.swift */; }; 4C198DEF29F88C6B004C165C /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */; }; @@ -339,6 +340,8 @@ 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; }; 9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C83F89229A937B900136C08 /* TextViewWrapper.swift */; }; 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; + BA4AB0AE2A63B9270070A32A /* AddEmojiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */; }; + BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */; }; BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; @@ -497,6 +500,7 @@ 4C0A3F92280F66F5000448DE /* ReplyMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyMap.swift; sourceTree = "<group>"; }; 4C0C03972A61E27B0098B3B8 /* primal.wasm */ = {isa = PBXFileReference; lastKnownFileType = file; name = primal.wasm; path = nostrscript/primal.wasm; sourceTree = SOURCE_ROOT; }; 4C0C03982A61E27B0098B3B8 /* bool_setting.wasm */ = {isa = PBXFileReference; lastKnownFileType = file; name = bool_setting.wasm; path = nostrscript/bool_setting.wasm; sourceTree = SOURCE_ROOT; }; + 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReactionsSettingsView.swift; sourceTree = "<group>"; }; 4C190F1F2A535FC200027FD5 /* CustomizeZapModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomizeZapModel.swift; sourceTree = "<group>"; }; 4C190F242A547D2000027FD5 /* LoadScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadScript.swift; sourceTree = "<group>"; }; 4C198DEB29F88C6B004C165C /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; }; @@ -843,6 +847,8 @@ 9609F057296E220800069BF3 /* BannerImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerImageView.swift; sourceTree = "<group>"; }; 9C83F89229A937B900136C08 /* TextViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextViewWrapper.swift; sourceTree = "<group>"; }; 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachMediaUtility.swift; sourceTree = "<group>"; }; + BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEmojiView.swift; sourceTree = "<group>"; }; + BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiListItemView.swift; sourceTree = "<group>"; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; }; @@ -1085,6 +1091,7 @@ 4C1A9A1B29DDCF8B00516EAC /* Settings */ = { isa = PBXGroup; children = ( + 4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */, 4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */, 4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */, 4C1A9A2029DDD3E100516EAC /* KeySettingsView.swift */, @@ -1092,6 +1099,8 @@ 4C1A9A2629DDE31900516EAC /* TranslationSettingsView.swift */, E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */, 5053ACA62A56DF3B00851AE3 /* DeveloperSettingsView.swift */, + BA4AB0AD2A63B9270070A32A /* AddEmojiView.swift */, + BA4AB0AF2A63B94D0070A32A /* EmojiListItemView.swift */, ); path = Settings; sourceTree = "<group>"; @@ -1939,6 +1948,7 @@ 4C9F18E429ABDE6D008C55EC /* MaybeAnonPfpView.swift in Sources */, 4CA5588329F33F5B00DC6A45 /* StringCodable.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, + 4C15C7152A55DE7A00D0A0DB /* ReactionsSettingsView.swift in Sources */, 4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */, 4C7D09742A0AEF9000943473 /* AlbyGradient.swift in Sources */, 4C687C272A6039500092C550 /* TestData.swift in Sources */, @@ -1947,6 +1957,7 @@ F75BA12F29A18EF500E10810 /* BookmarksView.swift in Sources */, 4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, + BA4AB0AE2A63B9270070A32A /* AddEmojiView.swift in Sources */, 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */, 3AB72AB9298ECF30004BB58C /* Translator.swift in Sources */, @@ -1976,6 +1987,7 @@ 4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */, 4C7D09602A098C5D00943473 /* WalletView.swift in Sources */, 4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */, + BA4AB0B02A63B94D0070A32A /* EmojiListItemView.swift in Sources */, 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, 4C30AC7629A5770900E2BD5A /* NotificationItemView.swift in Sources */, diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -173,6 +173,12 @@ class UserSettingsStore: ObservableObject { @Setting(key: "developer_mode", default_value: false) var developer_mode: Bool + + @Setting(key: "emoji_reactions", default_value: default_emoji_reactions) + var emoji_reactions: [String] + + @Setting(key: "default_emoji_reaction", default_value: "🤙") + var default_emoji_reaction: String // Helper for inverse of disable_animation. // disable_animation was introduced as a setting first, but it's more natural for the settings UI to show the inverse. diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift @@ -27,6 +27,7 @@ enum Route: Hashable { case NotificationSettings(settings: UserSettingsStore) case ZapSettings(settings: UserSettingsStore) case TranslationSettings(settings: UserSettingsStore) + case ReactionsSettings(settings: UserSettingsStore) case SearchSettings(settings: UserSettingsStore) case DeveloperSettings(settings: UserSettingsStore) case Thread(thread: ThreadModel) @@ -81,6 +82,8 @@ enum Route: Hashable { ZapSettingsView(settings: settings) case .TranslationSettings(let settings): TranslationSettingsView(settings: settings) + case .ReactionsSettings(let settings): + ReactionsSettingsView(settings: settings) case .SearchSettings(let settings): SearchSettingsView(settings: settings) case .DeveloperSettings(let settings): @@ -154,6 +157,8 @@ enum Route: Hashable { return true case (.SearchSettings, .SearchSettings): return true + case (.ReactionsSettings, .ReactionsSettings): + return true case (.DeveloperSettings, .DeveloperSettings): return true case (.Thread(let lhs_threadModel), .Thread(thread: let rhs_threadModel)): @@ -233,6 +238,8 @@ enum Route: Hashable { hasher.combine("zapSettings") case .TranslationSettings: hasher.combine("translationSettings") + case .ReactionsSettings: + hasher.combine("reactionsSettings") case .SearchSettings: hasher.combine("searchSettings") case .DeveloperSettings: diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift @@ -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 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 = [] + } } diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift @@ -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")) { diff --git a/damus/Views/Settings/AddEmojiView.swift b/damus/Views/Settings/AddEmojiView.swift @@ -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 + } + } + } + } +} diff --git a/damus/Views/Settings/EmojiListItemView.swift b/damus/Views/Settings/EmojiListItemView.swift @@ -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) + } + } +} diff --git a/damus/Views/Settings/ReactionsSettingsView.swift b/damus/Views/Settings/ReactionsSettingsView.swift @@ -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()) + } +}