From bc50f08ca23c87710b4ec0a7aecb4ee0413a2fc3 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 24 Feb 2023 13:12:31 -0500 Subject: [PATCH] Zaps, Likes and Replies in Public Chats and Private Messages --- .../vitorpamplona/amethyst/model/Account.kt | 38 +- .../ui/note/ChatroomMessageCompose.kt | 97 ++- .../amethyst/ui/note/ReactionsRow.kt | 575 ++++++++++-------- .../amethyst/ui/note/TimeAgoFormatter.kt | 16 + .../amethyst/ui/screen/ChatroomFeedView.kt | 5 +- .../ui/screen/loggedIn/AccountViewModel.kt | 8 + .../ui/screen/loggedIn/ChannelScreen.kt | 64 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 53 +- 8 files changed, 564 insertions(+), 292 deletions(-) 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 88825d183..b4d7d2148 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -289,14 +289,50 @@ class Account( LocalCache.consume(signedEvent, null) } + fun createPrivateMessageWithReply( + recipientPubKey: ByteArray, + msg: String, + replyTos: List? = null, mentions: List? = null, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000, + publishedRecipientPubKey: ByteArray? = null, + advertiseNip18: Boolean = true + ): PrivateDmEvent { + val content = Utils.encrypt( + if (advertiseNip18) { + PrivateDmEvent.nip18Advertisement + } else { "" } + msg, + privateKey, + recipientPubKey) + val pubKey = Utils.pubkeyCreate(privateKey) + val tags = mutableListOf>() + publishedRecipientPubKey?.let { + tags.add(listOf("p", publishedRecipientPubKey.toHex())) + } + replyTos?.forEach { + tags.add(listOf("e", it)) + } + mentions?.forEach { + tags.add(listOf("p", it)) + } + val id = Event.generateId(pubKey, createdAt, PrivateDmEvent.kind, tags, content) + val sig = Utils.sign(id, privateKey) + return PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) + } + fun sendPrivateMeesage(message: String, toUser: String, replyingTo: Note? = null) { if (!isWriteable()) return val user = LocalCache.users[toUser] ?: return - val signedEvent = PrivateDmEvent.create( + val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null } + val mentionsHex = emptyList() + + val signedEvent = createPrivateMessageWithReply( recipientPubKey = user.pubkey(), publishedRecipientPubKey = user.pubkey(), msg = message, + replyTos = repliesToHex, + mentions = mentionsHex, privateKey = loggedIn.privKey!!, advertiseNip18 = false ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 9b27f9f80..7b8972333 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -1,34 +1,48 @@ package com.vitorpamplona.amethyst.ui.note +import android.content.Context +import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,16 +52,23 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import coil.compose.AsyncImage import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.RoboHashCache +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent @@ -56,13 +77,15 @@ import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.launch val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) @OptIn(ExperimentalFoundationApi::class) @Composable -fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController) { +fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote: Boolean = false, accountViewModel: AccountViewModel, navController: NavController, onWantsToReply: (Note) -> Unit) { val noteState by baseNote.live().metadata.observeAsState() val note = noteState?.note @@ -121,17 +144,22 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote Column() { Row( - modifier = Modifier.fillMaxWidth(1f).padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp - ), + modifier = Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp + ), horizontalArrangement = alignment ) { + var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } Row( horizontalArrangement = alignment, - modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f) + modifier = Modifier.fillMaxWidth(if (innerQuote) 1f else 0.85f).onSizeChanged { + availableBubbleSize = it + }, ) { Surface( @@ -143,8 +171,12 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote onLongClick = { popupExpanded = true } ) ) { + var bubbleSize by remember { mutableStateOf(IntSize.Zero) } + Column( - modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 5.dp), + modifier = Modifier.padding(start = 10.dp, end = 5.dp, bottom = 5.dp).onSizeChanged { + bubbleSize = it + }, ) { val authorState by note.author!!.live().metadata.observeAsState() @@ -195,7 +227,8 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote null, innerQuote = true, accountViewModel = accountViewModel, - navController = navController + navController = navController, + onWantsToReply = onWantsToReply ) } } @@ -244,20 +277,37 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier.padding(top = 2.dp) - ) { - Text( - timeAgoLong(note.event?.createdAt), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 12.sp + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.padding(top = 2.dp).then( + with(LocalDensity.current) { + Modifier.widthIn(bubbleSize.width.toDp(), availableBubbleSize.width.toDp()) + } ) + ) { + Row() { + Text( + timeAgoShort(note.event?.createdAt), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 12.sp + ) - RelayBadges(note) + RelayBadges(note) + + Spacer(modifier = Modifier.width(10.dp)) + } + + Row() { + LikeReaction(baseNote, accountViewModel) + Spacer(modifier = Modifier.width(10.dp)) + ZapReaction(baseNote, accountViewModel) + Spacer(modifier = Modifier.width(10.dp)) + ReplyReaction(baseNote, accountViewModel, showCounter = false) { + onWantsToReply(baseNote) + } + } } } } - } NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) @@ -266,8 +316,6 @@ fun ChatroomMessageCompose(baseNote: Note, routeForLastRead: String?, innerQuote } } - - @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() @@ -283,7 +331,10 @@ private fun RelayBadges(baseNote: Note) { FlowRow(Modifier.padding(start = 10.dp)) { relaysToDisplay.forEach { val url = it.removePrefix("wss://") - Box(Modifier.size(15.dp).padding(1.dp)) { + Box( + Modifier + .size(15.dp) + .padding(1.dp)) { AsyncImage( model = "https://${url}/favicon.ico", placeholder = BitmapPainter(RoboHashCache.get(ctx, url)), @@ -295,7 +346,7 @@ private fun RelayBadges(baseNote: Note) { .fillMaxSize(1f) .clip(shape = CircleShape) .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://" + url) } ) + .clickable(onClick = { uri.openUri("https://" + url) }) ) } } 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 6a9a85d35..9dfcad496 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.note +import android.content.Context import android.widget.Toast import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi @@ -49,6 +50,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.KeyboardCapitalization @@ -78,6 +80,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import java.math.BigDecimal import java.math.RoundingMode +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -87,23 +90,6 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return - val reactionsState by baseNote.live().reactions.observeAsState() - val reactedNote = reactionsState?.note - - val boostsState by baseNote.live().boosts.observeAsState() - val boostedNote = boostsState?.note - - val zapsState by baseNote.live().zaps.observeAsState() - val zappedNote = zapsState?.note - - val repliesState by baseNote.live().replies.observeAsState() - val replies = repliesState?.note?.replies ?: emptySet() - - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - val uri = LocalUriHandler.current - val context = LocalContext.current - val scope = rememberCoroutineScope() - var wantsToReplyTo by remember { mutableStateOf(null) } @@ -118,256 +104,337 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { if (wantsToQuote != null) NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) - var wantsToZap by remember { mutableStateOf(false) } - var wantsToChangeZapAmount by remember { mutableStateOf(false) } - - var wantsToBoost by remember { mutableStateOf(false) } - Row( - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth(), + modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (account.isWriteable()) - wantsToReplyTo = baseNote - else - scope.launch { - Toast.makeText( - context, - "Login with a Private key to be able to reply", - Toast.LENGTH_SHORT - ).show() - } - } - ) { - Icon( - painter = painterResource(R.drawable.ic_comment), - null, - modifier = Modifier.size(15.dp), - tint = grayTint, - ) + ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) { + wantsToReplyTo = baseNote } - Text( - " ${showCount(replies.size)}", - fontSize = 14.sp, - color = grayTint, - modifier = Modifier.weight(1f) - ) - - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (account.isWriteable()) - wantsToBoost = true - else - scope.launch { - Toast.makeText( - context, - "Login with a Private key to be able to boost posts", - Toast.LENGTH_SHORT - ).show() - } - } - ) { - if (wantsToBoost) { - BoostTypeChoicePopup( - baseNote, - accountViewModel, - onDismiss = { - wantsToBoost = false - }, - onQuote = { - wantsToBoost = false - wantsToQuote = baseNote - } - ) - } - - if (boostedNote?.isBoostedBy(account.userProfile()) == true) { - Icon( - painter = painterResource(R.drawable.ic_retweeted), - null, - modifier = Modifier.size(20.dp), - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_retweet), - null, - modifier = Modifier.size(20.dp), - tint = grayTint - ) - } + BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) { + wantsToQuote = baseNote } - Text( - " ${showCount(boostedNote?.boosts?.size)}", - fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.weight(1f) - ) + LikeReaction(baseNote, accountViewModel, Modifier.weight(1f)) - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { - if (account.isWriteable()) - accountViewModel.reactTo(baseNote) - else - scope.launch { - Toast.makeText( - context, - "Login with a Private key to like Posts", - Toast.LENGTH_SHORT - ).show() - } - } - ) { - if (reactedNote?.isReactedBy(account.userProfile()) == true) { - Icon( - painter = painterResource(R.drawable.ic_liked), - null, - modifier = Modifier.size(16.dp), - tint = Color.Unspecified - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_like), - null, - modifier = Modifier.size(16.dp), - tint = grayTint - ) - } - } + ZapReaction(baseNote, accountViewModel, Modifier.weight(1f)) - Text( - " ${showCount(reactedNote?.reactions?.size)}", - fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.weight(1f) - ) - - - Row( - modifier = Modifier - .then(Modifier.size(20.dp)) - .combinedClickable( - role = Role.Button, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false, radius = 24.dp), - onClick = { - if (account.zapAmountChoices.isEmpty()) { - scope.launch { - Toast - .makeText( - context, - "No Zap Amount Setup. Long Press to change", - Toast.LENGTH_SHORT - ) - .show() - } - } else if (!account.isWriteable()) { - scope.launch { - Toast - .makeText( - context, - "Login with a Private key to be able to send Zaps", - Toast.LENGTH_SHORT - ) - .show() - } - } else if (account.zapAmountChoices.size == 1) { - accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) { - scope.launch { - Toast - .makeText(context, it, Toast.LENGTH_SHORT) - .show() - } - } - } else if (account.zapAmountChoices.size > 1) { - wantsToZap = true - } - }, - onLongClick = { - wantsToChangeZapAmount = true - } - ) - ) { - if (wantsToZap) { - ZapAmountChoicePopup( - baseNote, - accountViewModel, - onDismiss = { - wantsToZap = false - }, - onChangeAmount = { - wantsToZap = false - wantsToChangeZapAmount = true - } - ) - } - if (wantsToChangeZapAmount) { - UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account) - } - - if (zappedNote?.isZappedBy(account.userProfile()) == true) { - Icon( - imageVector = Icons.Default.Bolt, - contentDescription = "Zaps", - modifier = Modifier.size(20.dp), - tint = BitcoinOrange - ) - } else { - Icon( - imageVector = Icons.Outlined.Bolt, - contentDescription = "Zaps", - modifier = Modifier.size(20.dp), - tint = grayTint - ) - } - } - - Text( - showAmount(zappedNote?.zappedAmount()), - fontSize = 14.sp, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.weight(1f) - ) - - IconButton( - modifier = Modifier.then(Modifier.size(20.dp)), - onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } - ) { - Icon( - imageVector = Icons.Outlined.BarChart, - null, - modifier = Modifier.size(19.dp), - tint = grayTint - ) - } - - Row(modifier = Modifier.weight(1f)) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") - .crossfade(true) - .diskCachePolicy(CachePolicy.DISABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - .build(), - contentDescription = "View count", - modifier = Modifier.height(24.dp), - colorFilter = ColorFilter.tint(grayTint) - ) - } + ViewCountReaction(baseNote, Modifier.weight(1f)) } } +@Composable +fun ReplyReaction( + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier, + showCounter: Boolean = true, + onPress: () -> Unit, +) { + val repliesState by baseNote.live().replies.observeAsState() + val replies = repliesState?.note?.replies ?: emptySet() + + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() + + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { + if (accountViewModel.isWriteable()) + onPress() + else + scope.launch { + Toast.makeText( + context, + "Login with a Private key to be able to reply", + Toast.LENGTH_SHORT + ).show() + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(15.dp), + tint = grayTint, + ) + } + + if (showCounter) + Text( + " ${showCount(replies.size)}", + fontSize = 14.sp, + color = grayTint, + modifier = textModifier + ) +} + +@Composable +private fun BoostReaction( + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier, + onQuotePress: () -> Unit, +) { + val boostsState by baseNote.live().boosts.observeAsState() + val boostedNote = boostsState?.note + + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var wantsToBoost by remember { mutableStateOf(false) } + + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { + if (accountViewModel.isWriteable()) + wantsToBoost = true + else + scope.launch { + Toast.makeText( + context, + "Login with a Private key to be able to boost posts", + Toast.LENGTH_SHORT + ).show() + } + } + ) { + if (wantsToBoost) { + BoostTypeChoicePopup( + baseNote, + accountViewModel, + onDismiss = { + wantsToBoost = false + }, + onQuote = { + wantsToBoost = false + onQuotePress() + } + ) + } + + if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_retweet), + null, + modifier = Modifier.size(20.dp), + tint = grayTint + ) + } + } + + Text( + " ${showCount(boostedNote?.boosts?.size)}", + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = textModifier + ) +} + +@Composable +fun LikeReaction( + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier +) { + val reactionsState by baseNote.live().reactions.observeAsState() + val reactedNote = reactionsState?.note + + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() + + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { + if (accountViewModel.isWriteable()) + accountViewModel.reactTo(baseNote) + else + scope.launch { + Toast.makeText( + context, + "Login with a Private key to like Posts", + Toast.LENGTH_SHORT + ).show() + } + } + ) { + if (reactedNote?.isReactedBy(accountViewModel.userProfile()) == true) { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Modifier.size(16.dp), + tint = Color.Unspecified + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_like), + null, + modifier = Modifier.size(16.dp), + tint = grayTint + ) + } + } + + Text( + " ${showCount(reactedNote?.reactions?.size)}", + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = textModifier + ) +} + + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun ZapReaction( + baseNote: Note, + accountViewModel: AccountViewModel, + textModifier: Modifier = Modifier, +) { + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + + val zapsState by baseNote.live().zaps.observeAsState() + val zappedNote = zapsState?.note + + var wantsToZap by remember { mutableStateOf(false) } + var wantsToChangeZapAmount by remember { mutableStateOf(false) } + + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val context = LocalContext.current + val scope = rememberCoroutineScope() + + Row( + modifier = Modifier + .then(Modifier.size(20.dp)) + .combinedClickable( + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false, radius = 24.dp), + onClick = { + if (account.zapAmountChoices.isEmpty()) { + scope.launch { + Toast + .makeText( + context, + "No Zap Amount Setup. Long Press to change", + Toast.LENGTH_SHORT + ) + .show() + } + } else if (!accountViewModel.isWriteable()) { + scope.launch { + Toast + .makeText( + context, + "Login with a Private key to be able to send Zaps", + Toast.LENGTH_SHORT + ) + .show() + } + } else if (account.zapAmountChoices.size == 1) { + accountViewModel.zap(baseNote, account.zapAmountChoices.first() * 1000, "", context) { + scope.launch { + Toast + .makeText(context, it, Toast.LENGTH_SHORT) + .show() + } + } + } else if (account.zapAmountChoices.size > 1) { + wantsToZap = true + } + }, + onLongClick = { + wantsToChangeZapAmount = true + } + ) + ) { + if (wantsToZap) { + ZapAmountChoicePopup( + baseNote, + accountViewModel, + onDismiss = { + wantsToZap = false + }, + onChangeAmount = { + wantsToZap = false + wantsToChangeZapAmount = true + } + ) + } + if (wantsToChangeZapAmount) { + UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account) + } + + if (zappedNote?.isZappedBy(account.userProfile()) == true) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = "Zaps", + modifier = Modifier.size(20.dp), + tint = BitcoinOrange + ) + } else { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = "Zaps", + modifier = Modifier.size(20.dp), + tint = grayTint + ) + } + } + + Text( + showAmount(zappedNote?.zappedAmount()), + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + modifier = textModifier + ) +} + +@Composable +private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) { + val uri = LocalUriHandler.current + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + + IconButton( + modifier = Modifier.then(Modifier.size(20.dp)), + onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") } + ) { + Icon( + imageVector = Icons.Outlined.BarChart, + null, + modifier = Modifier.size(19.dp), + tint = grayTint + ) + } + + Row(modifier = textModifier) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000") + .crossfade(true) + .diskCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(CachePolicy.ENABLED) + .build(), + contentDescription = "View count", + modifier = Modifier.height(24.dp), + colorFilter = ColorFilter.tint(grayTint) + ) + } +} @OptIn(ExperimentalLayoutApi::class) @Composable @@ -415,7 +482,7 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo @OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable -private fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit) { +fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit) { val context = LocalContext.current val scope = rememberCoroutineScope() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index 55483454b..15fd6e134 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -22,6 +22,22 @@ fun timeAgo(mills: Long?): String { .replace(" days ago", "d") } +fun timeAgoShort(mills: Long?): String { + if (mills == null) return " " + + var humanReadable = DateUtils.getRelativeTimeSpanString( + mills * 1000, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_ALL + ).toString() + if (humanReadable.startsWith("In") || humanReadable.startsWith("0")) { + humanReadable = "now"; + } + + return humanReadable +} + fun timeAgoLong(mills: Long?): String { if (mills == null) return " " diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt index 209cabaeb..eee96c38c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomFeedView.kt @@ -18,11 +18,12 @@ import androidx.compose.runtime.collectAsState import androidx.navigation.NavController import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?) { +fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewModel, navController: NavController, routeForLastRead: String?, onWantsToReply: (Note) -> Unit ) { val feedState by viewModel.feedContent.collectAsState() var isRefreshing by remember { mutableStateOf(false) } @@ -65,7 +66,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode ) { var previousDate: String = "" itemsIndexed(state.feed.value, key = { index, item -> item.idHex }) { index, item -> - ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController) + ChatroomMessageCompose(item, routeForLastRead, accountViewModel = accountViewModel, navController = navController, onWantsToReply = onWantsToReply) } } } 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 887fd1062..13167aeb2 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 @@ -22,6 +22,14 @@ class AccountViewModel(private val account: Account): ViewModel() { val accountLiveData: LiveData = account.live.map { it } val accountLanguagesLiveData: LiveData = account.liveLanguages.map { it } + fun isWriteable(): Boolean { + return account.isWriteable() + } + + fun userProfile(): User { + return account.userProfile() + } + fun reactTo(note: Note) { account.reactTo(note) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index b016ec5f5..66651f3f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -4,10 +4,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,12 +21,14 @@ import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Share @@ -74,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import nostr.postr.toNpub @@ -84,6 +90,7 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun if (account != null && channelId != null) { val newPost = remember { mutableStateOf(TextFieldValue("")) } + val replyTo = remember { mutableStateOf(null) } ChannelFeedFilter.loadMessagesBetween(account, channelId) NostrChannelDataSource.loadMessagesBetween(channelId) @@ -130,13 +137,47 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun .padding(vertical = 0.dp) .weight(1f, true) ) { - ChatroomFeedView(feedViewModel, accountViewModel, navController, "Channel/${channelId}") + ChatroomFeedView(feedViewModel, accountViewModel, navController, "Channel/${channelId}") { + replyTo.value = it + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + val replyingNote = replyTo.value + if (replyingNote != null) { + Row(Modifier.padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + navController = navController, + onWantsToReply = { + replyTo.value = it + } + ) + } + + Column(Modifier.padding(end = 10.dp)) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = { replyTo.value = null } + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(end = 5.dp).size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } } //LAST ROW - Row(modifier = Modifier - .padding(10.dp) - .fillMaxWidth(), + Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { @@ -158,8 +199,9 @@ fun ChannelScreen(channelId: String?, accountViewModel: AccountViewModel, accoun trailingIcon = { PostButton( onPost = { - account.sendChannelMeesage(newPost.value.text, channel.idHex, null, null) + account.sendChannelMeesage(newPost.value.text, channel.idHex, replyTo.value, null) newPost.value = TextFieldValue("") + replyTo.value = null feedViewModel.refresh() // Don't wait a full second before updating }, newPost.value.text.isNotBlank(), @@ -220,7 +262,9 @@ fun ChannelHeader(baseChannel: Channel, account: Account, accountStateViewModel: } } - Row(modifier = Modifier.height(35.dp).padding(bottom = 3.dp)) { + Row(modifier = Modifier + .height(35.dp) + .padding(bottom = 3.dp)) { NoteCopyButton(channel) if (channel.creator == account.userProfile()) { @@ -253,7 +297,9 @@ private fun NoteCopyButton( var popupExpanded by remember { mutableStateOf(false) } Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = { popupExpanded = true }, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults @@ -288,7 +334,9 @@ private fun EditButton(account: Account, channel: Channel) { NewChannelView({ wantsToPost = false }, account = account, channel) Button( - modifier = Modifier.padding(horizontal = 3.dp).width(50.dp), + modifier = Modifier + .padding(horizontal = 3.dp) + .width(50.dp), onClick = { wantsToPost = true }, shape = RoundedCornerShape(20.dp), colors = ButtonDefaults diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 5b456bbbc..547f17819 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -4,20 +4,26 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -41,6 +47,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.vitorpamplona.amethyst.RoboHashCache +import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource @@ -49,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.actions.PostButton import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter +import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -59,6 +67,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr if (account != null && userId != null) { val newPost = remember { mutableStateOf(TextFieldValue("")) } + val replyTo = remember { mutableStateOf(null) } ChatroomFeedFilter.loadMessagesBetween(account, userId) NostrChatroomDataSource.loadMessagesBetween(account, userId) @@ -104,12 +113,47 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr .padding(vertical = 0.dp) .weight(1f, true) ) { - ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}") + ChatroomFeedView(feedViewModel, accountViewModel, navController, "Room/${userId}") { + replyTo.value = it + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + val replyingNote = replyTo.value + if (replyingNote != null) { + Row(Modifier.padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + navController = navController, + onWantsToReply = { + replyTo.value = it + } + ) + } + + Column(Modifier.padding(end = 10.dp)) { + IconButton( + modifier = Modifier.size(30.dp), + onClick = { replyTo.value = null } + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier.padding(end = 5.dp).size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + } } //LAST ROW - Row(modifier = Modifier - .padding(10.dp) + Row(modifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp) .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically @@ -132,8 +176,9 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr trailingIcon = { PostButton( onPost = { - account.sendPrivateMeesage(newPost.value.text, userId) + account.sendPrivateMeesage(newPost.value.text, userId, replyTo.value) newPost.value = TextFieldValue("") + replyTo.value = null feedViewModel.refresh() // Don't wait a full second before updating }, newPost.value.text.isNotBlank(),