diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 5e85af2d2..8ee3898e0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -49,6 +49,7 @@ private object PrefKeys { const val LANGUAGE_PREFS = "languagePreferences" const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" + const val REACTION_CHOICES = "reactionChoices" const val DEFAULT_ZAPTYPE = "defaultZapType" const val DEFAULT_FILE_SERVER = "defaultFileServer" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" @@ -208,6 +209,7 @@ object LocalPreferences { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences)) putString(PrefKeys.TRANSLATE_TO, account.translateTo) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) + putString(PrefKeys.REACTION_CHOICES, gson.toJson(account.reactionChoices)) putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer)) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList) @@ -258,6 +260,11 @@ object LocalPreferences { object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) + val reactionChoices = gson.fromJson>( + getString(PrefKeys.REACTION_CHOICES, "[]"), + object : TypeToken>() {}.type + ).ifEmpty { listOf("+") } ?: listOf("+") + val defaultZapType = gson.fromJson( getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"), object : TypeToken() {}.type @@ -314,28 +321,29 @@ object LocalPreferences { val warnAboutReports = getBoolean(PrefKeys.WARN_ABOUT_REPORTS, true) val a = Account( - Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), - followingChannels, - hiddenUsers, - localRelays, - dontTranslateFrom, - languagePreferences, - translateTo, - zapAmountChoices, - defaultZapType, - defaultFileServer, - defaultHomeFollowList, - defaultStoriesFollowList, - defaultNotificationFollowList, - zapPaymentRequestServer, - hideDeleteRequestDialog, - hideBlockAlertDialog, - latestContactList, - proxy, - proxyPort, - showSensitiveContent, - warnAboutReports, - filterSpam + loggedIn = Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()), + followingChannels = followingChannels, + hiddenUsers = hiddenUsers, + localRelays = localRelays, + dontTranslateFrom = dontTranslateFrom, + languagePreferences = languagePreferences, + translateTo = translateTo, + zapAmountChoices = zapAmountChoices, + reactionChoices = reactionChoices, + defaultZapType = defaultZapType, + defaultFileServer = defaultFileServer, + defaultHomeFollowList = defaultHomeFollowList, + defaultStoriesFollowList = defaultStoriesFollowList, + defaultNotificationFollowList = defaultNotificationFollowList, + zapPaymentRequest = zapPaymentRequestServer, + hideDeleteRequestDialog = hideDeleteRequestDialog, + hideBlockAlertDialog = hideBlockAlertDialog, + backupContactList = latestContactList, + proxy = proxy, + proxyPort = proxyPort, + showSensitiveContent = showSensitiveContent, + warnAboutPostsWithReports = warnAboutReports, + filterSpamFromStrangers = filterSpam ) return a diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 1c67d5a0f..a446f2c0b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -56,6 +56,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var reactionChoices: List = listOf("+"), var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD, var defaultHomeFollowList: String = KIND3_FOLLOWS, @@ -143,8 +144,8 @@ class Account( } } - fun reactionTo(note: Note): List { - return note.reactedBy(userProfile(), "+") + fun reactionTo(note: Note, reaction: String): List { + return note.reactedBy(userProfile(), reaction) } fun hasBoosted(note: Note): Boolean { @@ -155,20 +156,20 @@ class Account( return note.boostedBy(userProfile()) } - fun hasReacted(note: Note): Boolean { - return note.hasReacted(userProfile(), "+") + fun hasReacted(note: Note, reaction: String): Boolean { + return note.hasReacted(userProfile(), reaction) } - fun reactTo(note: Note) { + fun reactTo(note: Note, reaction: String) { if (!isWriteable()) return - if (hasReacted(note)) { + if (hasReacted(note, reaction)) { // has already liked this note return } note.event?.let { - val event = ReactionEvent.createLike(it, loggedIn.privKey!!) + val event = ReactionEvent.create(reaction, it, loggedIn.privKey!!) Client.send(event) LocalCache.consume(event) } @@ -863,6 +864,12 @@ class Account( saveable.invalidateData() } + fun changeReactionTypes(newTypes: List) { + reactionChoices = newTypes + live.invalidateData() + saveable.invalidateData() + } + fun changeZapPaymentRequest(newServer: Nip47URI?) { zapPaymentRequest = newServer live.invalidateData() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 95f9e9728..1a0308cf3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -554,17 +554,8 @@ object LocalCache { // Log.d("RE", "New Reaction ${event.content} (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${formattedDateTime(event.createdAt)}") - if ( - event.content == "" || - event.content == "+" || - event.content == "\u2764\uFE0F" || // red heart - event.content == "\uD83E\uDD19" || // call me hand - event.content == "\uD83D\uDC4D" // thumbs up - ) { - // Counts the replies - repliesTo.forEach { - it.addReaction(note) - } + repliesTo.forEach { + it.addReaction(note) } refreshObservers(note) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 6153c7ff0..99d1526f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -43,7 +43,7 @@ open class Note(val idHex: String) { // These fields are updated every time an event related to this note is received. var replies = setOf() private set - var reactions = setOf() + var reactions = mapOf>() private set var boosts = setOf() private set @@ -134,8 +134,20 @@ open class Note(val idHex: String) { liveSet?.boosts?.invalidateData() } fun removeReaction(note: Note) { - reactions = reactions - note - liveSet?.reactions?.invalidateData() + val reaction = note.event?.content() ?: "+" + + if (reaction in reactions.keys && reactions[reaction]?.contains(note) == true) { + reactions[reaction]?.let { + val newList = it.minus(note) + if (newList.isEmpty()) { + reactions = reactions.minus(reaction) + } else { + reactions = reactions + Pair(reaction, newList) + } + + liveSet?.reactions?.invalidateData() + } + } } fun removeReport(deleteNote: Note) { @@ -203,8 +215,13 @@ open class Note(val idHex: String) { } fun addReaction(note: Note) { - if (note !in reactions) { - reactions = reactions + note + val reaction = note.event?.content() ?: "+" + + if (reaction !in reactions.keys) { + reactions = reactions + Pair(reaction, setOf(note)) + liveSet?.reactions?.invalidateData() + } else if (reactions[reaction]?.contains(note) == false) { + reactions = reactions + Pair(reaction, (reactions[reaction] ?: emptySet()) + note) liveSet?.reactions?.invalidateData() } } @@ -243,8 +260,10 @@ open class Note(val idHex: String) { } } - fun isReactedBy(user: User): Boolean { - return reactions.any { it.author?.pubkeyHex == user.pubkeyHex } + fun isReactedBy(user: User): String? { + return reactions.filter { + it.value.any { it.author?.pubkeyHex == user.pubkeyHex } + }.keys.firstOrNull() } fun isBoostedBy(user: User): Boolean { @@ -269,6 +288,10 @@ open class Note(val idHex: String) { }.flatten() } + fun countReactions(): Int { + return reactions.values.sumOf { it.size } + } + fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal { // Regular Zap Receipts val completedZaps = zaps.asSequence() @@ -361,7 +384,11 @@ open class Note(val idHex: String) { } fun reactedBy(loggedIn: User, content: String): List { - return reactions.filter { it.author == loggedIn && it.event?.content() == content } + return reactions[content]?.filter { it.author == loggedIn && it.event?.content() == content } ?: emptyList() + } + + fun reactedBy(loggedIn: User): List { + return reactions.filter { it.value.any { it.author == loggedIn } }.mapNotNull { it.key } } fun hasBoostedInTheLast5Minutes(loggedIn: User): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 7020a5229..ef3fe699f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -145,7 +145,7 @@ private fun Galeries( ) { val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } } val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } } - val likeEvents by remember { derivedStateOf { multiSetCard.likeEvents } } + val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } } val hasZapEvents by remember { derivedStateOf { multiSetCard.zapEvents.isNotEmpty() } } val hasBoostEvents by remember { derivedStateOf { multiSetCard.boostEvents.isNotEmpty() } } @@ -160,38 +160,52 @@ private fun Galeries( } if (hasLikeEvents) { - RenderLikeGallery(likeEvents, backgroundColor, nav, accountViewModel) + likeEvents.forEach { + RenderLikeGallery(it.key, it.value, backgroundColor, nav, accountViewModel) + } } } @Composable fun RenderLikeGallery( + reactionType: String, likeEvents: ImmutableList, backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { - Row(remember { Modifier.fillMaxWidth() }) { - Box( - modifier = remember { - Modifier - .width(55.dp) - .padding(end = 5.dp) - } - ) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, + val isNotEmpty = remember(likeEvents) { + likeEvents.isNotEmpty() + } + + if (isNotEmpty) { + Row(remember { Modifier.fillMaxWidth() }) { + Box( modifier = remember { Modifier - .size(16.dp) + .width(55.dp) + .padding(end = 5.dp) + } + ) { + val modifier = remember { + Modifier .align(Alignment.TopEnd) - }, - tint = Color.Unspecified - ) - } + } - AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel) + when (reactionType) { + "+" -> Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { modifier.size(18.dp) }, + tint = Color.Unspecified + ) + "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) + else -> Text(text = reactionType, modifier = modifier) + } + } + + AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 2cc5b9f61..d83fea860 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -190,7 +191,7 @@ private fun ReactionDetailGallery( Column() { val zapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.mapNotNull { it.value?.let { zapEvent -> CombinedZap(it.key, zapEvent) } }.toImmutableList() } } val boostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.toImmutableList() } } - val likeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableList() } } + val likeEvents by remember(reactionsState) { derivedStateOf { baseNote.reactions.toImmutableMap() } } val hasZapEvents by remember(zapsState) { derivedStateOf { baseNote.zaps.isNotEmpty() } } val hasBoostEvents by remember(boostsState) { derivedStateOf { baseNote.boosts.isNotEmpty() } } @@ -215,12 +216,16 @@ private fun ReactionDetailGallery( } if (hasLikeEvents) { - RenderLikeGallery( - likeEvents, - backgroundColor, - nav, - accountViewModel - ) + likeEvents.forEach { + val reactions = remember(it.value) { it.value.toImmutableList() } + RenderLikeGallery( + it.key, + reactions, + backgroundColor, + nav, + accountViewModel + ) + } } } } @@ -429,6 +434,7 @@ fun BoostText(baseNote: Note, grayTint: Color) { ) } +@OptIn(ExperimentalFoundationApi::class) @Composable fun LikeReaction( baseNote: Note, @@ -444,29 +450,50 @@ fun LikeReaction( Modifier.size(iconSize) } - IconButton( - modifier = iconButtonModifier, - onClick = { - if (accountViewModel.isWriteable()) { - scope.launch(Dispatchers.IO) { - if (accountViewModel.hasReactedTo(baseNote)) { - accountViewModel.deleteReactionTo(baseNote) - } else { - accountViewModel.reactTo(baseNote) + var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } + var wantsToReact by remember { mutableStateOf(false) } + + Row( + verticalAlignment = CenterVertically, + modifier = iconButtonModifier.combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp), + onClick = { + likeClick( + baseNote, + accountViewModel, + scope, + context, + onMultipleChoices = { + wantsToReact = true } - } - } else { - scope.launch { - Toast.makeText( - context, - context.getString(R.string.login_with_a_private_key_to_like_posts), - Toast.LENGTH_SHORT - ).show() - } + ) + }, + onLongClick = { + wantsToChangeReactionSymbol = true } - } + ) ) { LikeIcon(baseNote, heartSize, grayTint, accountViewModel.userProfile()) + + if (wantsToChangeReactionSymbol) { + UpdateReactionTypeDialog({ wantsToChangeReactionSymbol = false }, accountViewModel = accountViewModel) + } + + if (wantsToReact) { + ReactionChoicePopup( + baseNote, + accountViewModel, + onDismiss = { + wantsToReact = false + }, + onChangeAmount = { + wantsToReact = false + wantsToChangeReactionSymbol = true + } + ) + } } LikeText(baseNote, grayTint) @@ -476,15 +503,15 @@ fun LikeReaction( fun LikeIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: User) { val reactionsState by baseNote.live().reactions.observeAsState() - var wasReactedByLoggedIn by remember(reactionsState) { - mutableStateOf(false) + var reactionType by remember(baseNote) { + mutableStateOf(null) } LaunchedEffect(key1 = reactionsState) { launch(Dispatchers.Default) { - val newWasReactedByLoggedIn = reactionsState?.note?.isReactedBy(loggedIn) == true - if (wasReactedByLoggedIn != newWasReactedByLoggedIn) { - wasReactedByLoggedIn = newWasReactedByLoggedIn + val newReactionType = reactionsState?.note?.isReactedBy(loggedIn) + if (reactionType != newReactionType) { + reactionType = newReactionType } } } @@ -493,13 +520,19 @@ fun LikeIcon(baseNote: Note, iconSize: Dp = 20.dp, grayTint: Color, loggedIn: Us Modifier.size(iconSize) } - if (wasReactedByLoggedIn) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = iconModifier, - tint = Color.Unspecified - ) + if (reactionType != null) { + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = iconModifier, + tint = Color.Unspecified + ) + } + "-" -> Text(text = "\uD83D\uDC4E") + else -> Text(text = reactionType!!) + } } else { Icon( painter = painterResource(R.drawable.ic_like), @@ -520,7 +553,7 @@ fun LikeText(baseNote: Note, grayTint: Color) { LaunchedEffect(key1 = reactionsState) { launch(Dispatchers.Default) { - val newReactionsCount = " " + showCount(reactionsState?.note?.reactions?.size) + val newReactionsCount = " " + showCount(reactionsState?.note?.countReactions()) if (reactionsCount != newReactionsCount) { reactionsCount = newReactionsCount } @@ -534,6 +567,47 @@ fun LikeText(baseNote: Note, grayTint: Color) { ) } +private fun likeClick( + baseNote: Note, + accountViewModel: AccountViewModel, + scope: CoroutineScope, + context: Context, + onMultipleChoices: () -> Unit +) { + if (accountViewModel.account.reactionChoices.isEmpty()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.no_reaction_type_setup_long_press_to_change), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + context.getString(R.string.login_with_a_private_key_to_be_able_to_send_zaps), + Toast.LENGTH_SHORT + ) + .show() + } + } else if (accountViewModel.account.reactionChoices.size == 1) { + scope.launch(Dispatchers.IO) { + val reaction = accountViewModel.account.reactionChoices.first() + if (accountViewModel.hasReactedTo(baseNote, reaction)) { + accountViewModel.deleteReactionTo(baseNote, reaction) + } else { + accountViewModel.reactTo(baseNote, reaction) + } + } + } else if (accountViewModel.account.reactionChoices.size > 1) { + onMultipleChoices() + } +} + @Composable @OptIn(ExperimentalFoundationApi::class) fun ZapReaction( @@ -863,6 +937,95 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo } } +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) +@Composable +fun ReactionChoicePopup( + baseNote: Note, + accountViewModel: AccountViewModel, + onDismiss: () -> Unit, + onChangeAmount: () -> Unit +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val scope = rememberCoroutineScope() + + val toRemove = remember { + baseNote.reactedBy(account.userProfile()).toSet() + } + + Popup( + alignment = Alignment.BottomCenter, + offset = IntOffset(0, -50), + onDismissRequest = { onDismiss() } + ) { + FlowRow(horizontalArrangement = Arrangement.Center) { + account.reactionChoices.forEach { reactionType -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.reactToOrDelete( + baseNote, + reactionType + ) + onDismiss() + } + }, + shape = ButtonBorder, + colors = ButtonDefaults + .buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + val thisModifier = remember(reactionType) { + Modifier.combinedClickable( + onClick = { + scope.launch(Dispatchers.IO) { + accountViewModel.reactToOrDelete( + baseNote, + reactionType + ) + onDismiss() + } + }, + onLongClick = { + onChangeAmount() + } + ) + } + + val removeSymbol = remember(reactionType) { + if (reactionType in toRemove) { + " ✖" + } else { + "" + } + } + + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { thisModifier.size(16.dp) }, + tint = Color.White + ) + Text(text = removeSymbol, color = Color.White, textAlign = TextAlign.Center, modifier = thisModifier) + } + "-" -> Text(text = "\uD83D\uDC4E$removeSymbol", color = Color.White, textAlign = TextAlign.Center, modifier = thisModifier) + else -> Text( + "$reactionType$removeSymbol", + color = Color.White, + textAlign = TextAlign.Center, + modifier = thisModifier + ) + } + } + } + } + } +} + @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun ZapAmountChoicePopup( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt new file mode 100644 index 000000000..4b13ff9f6 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -0,0 +1,231 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.SaveButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +class UpdateReactionTypeViewModel(val account: Account) : ViewModel() { + var nextChoice by mutableStateOf(TextFieldValue("")) + var reactionSet by mutableStateOf(listOf()) + + fun load() { + this.reactionSet = account.reactionChoices + } + + fun toListOfChoices(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } + + fun addChoice() { + val newValue = nextChoice.text.trim() + reactionSet = reactionSet + newValue + + nextChoice = TextFieldValue("") + } + + fun removeChoice(reaction: String) { + reactionSet = reactionSet - reaction + } + + fun sendPost() { + account.changeReactionTypes(reactionSet) + nextChoice = TextFieldValue("") + } + + fun cancel() { + nextChoice = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return reactionSet != account.reactionChoices + } + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): UpdateReactionTypeViewModel { + return UpdateReactionTypeViewModel(account) as UpdateReactionTypeViewModel + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UpdateReactionTypeDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) { + val postViewModel: UpdateReactionTypeViewModel = viewModel( + key = accountViewModel.userProfile().pubkeyHex, + factory = UpdateReactionTypeViewModel.Factory(accountViewModel.account) + ) + + LaunchedEffect(accountViewModel) { + postViewModel.load() + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false + ) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp).imePadding()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged() + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + postViewModel.reactionSet.forEach { reactionType -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = ButtonBorder, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + onClick = { + postViewModel.removeChoice(reactionType) + } + ) { + when (reactionType) { + "+" -> { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = remember { Modifier.size(16.dp) }, + tint = Color.White + ) + Text(text = " ✖", color = Color.White, textAlign = TextAlign.Center) + } + "-" -> Text(text = "\uD83D\uDC4E ✖", color = Color.White, textAlign = TextAlign.Center) + else -> Text(text = "$reactionType ✖", color = Color.White, textAlign = TextAlign.Center) + } + } + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_reaction_symbol)) }, + value = postViewModel.nextChoice, + onValueChange = { + postViewModel.nextChoice = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text + ), + placeholder = { + Text( + text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", + color = MaterialTheme.colors.placeholderText + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + + Button( + onClick = { postViewModel.addChoice() }, + shape = ButtonBorder, + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt index 8a3d57e3d..587daab5f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedState.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap @Immutable abstract class Card() { @@ -41,7 +43,12 @@ class ZapUserSetCard(val user: User, val zapEvents: ImmutableList) } @Immutable -class MultiSetCard(val note: Note, val boostEvents: ImmutableList, val likeEvents: ImmutableList, val zapEvents: ImmutableList) : Card() { +class MultiSetCard( + val note: Note, + val boostEvents: ImmutableList, + val likeEvents: ImmutableList, + val zapEvents: ImmutableList +) : Card() { val maxCreatedAt = maxOf( zapEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, @@ -54,6 +61,10 @@ class MultiSetCard(val note: Note, val boostEvents: ImmutableList, val lik boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE ) + val likeEventsByType = likeEvents.groupBy { it.event?.content() ?: "+" }.mapValues { + it.value.toImmutableList() + }.toImmutableMap() + override fun createdAt(): Long { return maxCreatedAt } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index ac0ffa1b5..b2360325e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -39,16 +39,25 @@ class AccountViewModel(val account: Account) : ViewModel() { return account.userProfile() } - fun reactTo(note: Note) { - account.reactTo(note) + fun reactTo(note: Note, reaction: String) { + account.reactTo(note, reaction) } - fun hasReactedTo(baseNote: Note): Boolean { - return account.hasReacted(baseNote) + fun reactToOrDelete(note: Note, reaction: String) { + val currentReactions = account.reactionTo(note, reaction) + if (currentReactions.isNotEmpty()) { + account.delete(currentReactions) + } else { + account.reactTo(note, reaction) + } } - fun deleteReactionTo(note: Note) { - account.delete(account.reactionTo(note)) + fun hasReactedTo(baseNote: Note, reaction: String): Boolean { + return account.hasReacted(baseNote, reaction) + } + + fun deleteReactionTo(note: Note, reaction: String) { + account.delete(account.reactionTo(note, reaction)) } fun hasBoosted(baseNote: Note): Boolean { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2deff8ad..a1a84b33d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -414,4 +414,7 @@ Recommends: Filter spam from strangers Warn when posts have reports from your follows + + New Reaction Symbol + No reaction types selected. Long Press to change