diff --git a/app/build.gradle b/app/build.gradle index 2e3b151c7..bf17bcf25 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -13,8 +13,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 165 - versionName "0.49.2" + versionCode 172 + versionName "0.50.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -177,6 +177,12 @@ dependencies { playImplementation platform('com.google.firebase:firebase-bom:32.0.0') playImplementation 'com.google.firebase:firebase-messaging-ktx' + // Charts + implementation "com.patrykandpatrick.vico:core:${vico_version}" + implementation "com.patrykandpatrick.vico:compose:${vico_version}" + implementation "com.patrykandpatrick.vico:views:${vico_version}" + implementation "com.patrykandpatrick.vico:compose-m2:${vico_version}" + // Automatic memory leak detection debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' 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 bf66a5f43..38d71ebda 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -593,18 +593,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.plus(note.address) ?: listOf(note.address), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + } Client.send(event) LocalCache.consume(event) @@ -615,18 +630,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address), - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + } Client.send(event) LocalCache.consume(event) @@ -637,18 +667,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents() ?: emptyList(), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!)?.minus(note.address) ?: listOf(), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + } Client.send(event) LocalCache.consume(event) @@ -665,18 +710,33 @@ class Account( val bookmarks = userProfile().latestBookmarkList - val event = BookmarkListEvent.create( - "bookmark", - bookmarks?.taggedEvents()?.minus(note.idHex), - bookmarks?.taggedUsers() ?: emptyList(), - bookmarks?.taggedAddresses() ?: emptyList(), + val event = if (note is AddressableNote) { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents() ?: emptyList(), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses()?.minus(note.address), - bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), - bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), - loggedIn.privKey!! - ) + loggedIn.privKey!! + ) + } else { + BookmarkListEvent.create( + "bookmark", + bookmarks?.taggedEvents()?.minus(note.idHex), + bookmarks?.taggedUsers() ?: emptyList(), + bookmarks?.taggedAddresses() ?: emptyList(), + + bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), + bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(), + + loggedIn.privKey!! + ) + } Client.send(event) LocalCache.consume(event) 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 16257be07..eea2d7851 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -553,17 +553,20 @@ object LocalCache { // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") val oldChannel = getOrCreateChannel(event.id) val author = getOrCreateUser(event.pubKey) + + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + if (event.createdAt <= oldChannel.updatedMetadataAt) { return // older data, does nothing } if (oldChannel.creator == null || oldChannel.creator == author) { oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - - val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) } } @@ -578,16 +581,18 @@ object LocalCache { if (event.createdAt > oldChannel.updatedMetadataAt) { if (oldChannel.creator == null || oldChannel.creator == author) { oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) - - val note = getOrCreateNote(event.id) - oldChannel.addNote(note) - note.loadEvent(event, author, emptyList()) - - refreshObservers(note) } } else { // Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") } + + val note = getOrCreateNote(event.id) + if (note.event == null) { + oldChannel.addNote(note) + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } } fun consume(event: ChannelMessageEvent, relay: Relay?) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index aba906e2a..8a2ceecee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -95,7 +95,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { BadgeAwardEvent.kind ), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), - limit = 400, + limit = 4000, since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt index bc5d60083..47183238e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LnWithdrawalUtil.kt @@ -4,7 +4,7 @@ import java.util.regex.Pattern object LnWithdrawalUtil { private val withdrawalPattern = Pattern.compile( - "lnurl.*", + "lnurl.+", Pattern.CASE_INSENSITIVE ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt index b9bc6ab55..aa80ac016 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt @@ -43,6 +43,7 @@ class TextNoteEvent( } findHashtags(msg).forEach { tags.add(listOf("t", it)) + tags.add(listOf("t", it.lowercase())) } extraTags?.forEach { tags.add(listOf("t", it)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 3fb91722f..33a02c40e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -70,7 +70,7 @@ object Client : RelayPool.Listener { if (relay == null) { RelayPool.send(signedEvent) } else { - val useConnectedRelayIfPresent = relays.filter { it.url == relay } + val useConnectedRelayIfPresent = RelayPool.getRelays(relay) if (useConnectedRelayIfPresent.isNotEmpty()) { useConnectedRelayIfPresent.forEach { @@ -103,7 +103,7 @@ object Client : RelayPool.Listener { onConnected(relay) GlobalScope.launch(Dispatchers.IO) { - delay(10000) // waits for a reply + delay(60000) // waits for a reply relay.disconnect() RelayPool.removeRelay(relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index 702bdc1b2..23f2c4a25 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -30,6 +30,10 @@ object RelayPool : Relay.Listener { return relays.firstOrNull() { it.url == url } } + fun getRelays(url: String): List { + return relays.filter { it.url == url } + } + fun loadRelays(relayList: List) { if (!relayList.isNullOrEmpty()) { relayList.forEach { addRelay(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 310eed8d1..eda028bf0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -75,6 +75,7 @@ import kotlinx.coroutines.withContext @Composable fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) { val searchBarViewModel: SearchBarViewModel = viewModel() + searchBarViewModel.account = account Dialog( onDismissRequest = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index a663d5d1e..dc8a01489 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.Bolt import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -62,6 +63,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner @@ -95,6 +97,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } + DisposableEffect(Unit) { + onDispose { + NostrSearchEventOrUserDataSource.clear() + } + } + Dialog( onDismissRequest = { onClose() }, properties = DialogProperties( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index a74a15d7a..c22115261 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.isValidURL @@ -188,6 +189,8 @@ open class NewPostViewModel : ViewModel() { userSuggestions = emptyList() userSuggestionAnchor = null userSuggestionsMainMessage = null + + NostrSearchEventOrUserDataSource.clear() } open fun findUrlInMessage(): String? { @@ -211,8 +214,10 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = true if (lastWord.startsWith("@") && lastWord.length > 2) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() } else { + NostrSearchEventOrUserDataSource.clear() userSuggestions = emptyList() } } @@ -225,8 +230,10 @@ open class NewPostViewModel : ViewModel() { userSuggestionAnchor = it.selection userSuggestionsMainMessage = false if (lastWord.startsWith("@") && lastWord.length > 2) { - userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() } else { + NostrSearchEventOrUserDataSource.clear() userSuggestions = emptyList() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index b5d08d167..c387bfa5d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -37,7 +37,7 @@ fun RobohashAsyncImage( robotSize.roundToPx() } - val imageRequest = remember(size, robot) { + val imageRequest = remember(robotSize, robot) { Robohash.imageRequest( context, robot, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 512e6c8e2..d1005265b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -8,6 +8,7 @@ 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.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -18,6 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel @@ -37,6 +39,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -50,7 +54,8 @@ fun AppNavigation( // Avoids creating ViewModels for performance reasons (up to 1 second delays) val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return + val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex } HomeNewThreadFeedFilter.account = account HomeConversationsFeedFilter.account = account @@ -67,6 +72,16 @@ fun AppNavigation( NotificationFeedFilter.account = account val notifFeedViewModel: NotificationViewModel = viewModel() + val userReactionsStatsModel: UserReactionsViewModel = viewModel() + val scope = rememberCoroutineScope() + + LaunchedEffect(accountHex) { + scope.launch(Dispatchers.IO) { + userReactionsStatsModel.load(account.userProfile()) + userReactionsStatsModel.initializeSuspend() + } + } + NavHost(navController, startDestination = Route.Home.route) { Route.Video.let { route -> composable(route.route, route.arguments, content = { @@ -135,6 +150,7 @@ fun AppNavigation( NotificationScreen( notifFeedViewModel = notifFeedViewModel, + userReactionsStatsModel = userReactionsStatsModel, accountViewModel = accountViewModel, navController = navController, scrollToTop = scrollToTop diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 1ebe2c432..cecad69ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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 @@ -23,6 +24,7 @@ import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -337,7 +339,16 @@ fun SimpleTextSpinner( modifier = modifier, contentAlignment = Alignment.Center ) { - Text(placeholder) + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(modifier = Modifier.size(20.dp)) + Text(placeholder) + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } Box( modifier = Modifier .matchParentSize() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index 98aa24cdb..a333a2b6c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @Composable -fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { +fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) { Column(modifier = modifier) { Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.dp)) { @@ -39,7 +39,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { horizontalArrangement = Arrangement.Center ) { Text( - text = stringResource(R.string.post_not_found), + text = stringResource(R.string.post_not_found) + if (idHex != null) ": $idHex" else "", modifier = Modifier.padding(30.dp), color = Color.Gray ) 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 ffb7dbea5..55fe08669 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 @@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -55,6 +56,7 @@ import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent @@ -67,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) @@ -82,27 +85,54 @@ fun ChatroomMessageCompose( navController: NavController, onWantsToReply: (Note) -> Unit ) { - val noteState by baseNote.live().metadata.observeAsState() - val note = noteState?.note - val accountState by accountViewModel.accountLiveData.observeAsState() - val account = accountState?.account ?: return + val account = remember(accountState) { accountState?.account } ?: return + val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return + + val noteState by baseNote.live().metadata.observeAsState() + val note = remember(noteState) { noteState?.note } ?: return val noteReportsState by baseNote.live().reports.observeAsState() - val noteForReports = noteReportsState?.note ?: return + val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return - val accountUser = account.userProfile() + val noteEvent = remember(noteState) { note.event } var popupExpanded by remember { mutableStateOf(false) } - var showHiddenNote by remember { mutableStateOf(false) } - val context = LocalContext.current.applicationContext - val scope = rememberCoroutineScope() + if (noteEvent == null) { + BlankNote( + Modifier.combinedClickable( + onClick = { }, + onLongClick = { popupExpanded = true } + ) + ) - if (note?.event == null) { - BlankNote(Modifier) - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { - if (!account.isHidden(noteForReports.author!!)) { + note.let { + NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel) + } + } else if (account.isHidden(noteForReports.author!!)) { + // Does nothing + } else { + var showHiddenNote by remember { mutableStateOf(false) } + var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } + + LaunchedEffect(key1 = noteReportsState, key2 = accountState) { + withContext(Dispatchers.IO) { + account.userProfile().let { loggedIn -> + val newCanPreview = note.author === loggedIn || + (note.author?.let { loggedIn.isFollowingCached(it) } ?: true) || + !(noteForReports.hasAnyReports()) + + val newIsAcceptable = account.isAcceptable(noteForReports) + + if (newIsAcceptable != isAcceptableAndCanPreview.first && newCanPreview != isAcceptableAndCanPreview.second) { + isAcceptableAndCanPreview = Pair(newIsAcceptable, newCanPreview) + } + } + } + } + + if (!isAcceptableAndCanPreview.first && !showHiddenNote) { HiddenNote( account.getRelevantReports(noteForReports), account.userProfile(), @@ -111,305 +141,436 @@ fun ChatroomMessageCompose( navController, onClick = { showHiddenNote = true } ) - } - } else { - var backgroundBubbleColor: Color - var alignment: Arrangement.Horizontal - var shape: Shape - - val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - - if (note.author == accountUser) { - backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) - alignment = Arrangement.End - shape = ChatBubbleShapeMe } else { - backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) - alignment = Arrangement.Start - shape = ChatBubbleShapeThem - } + val backgroundBubbleColor: Color + val alignment: Arrangement.Horizontal + val shape: Shape - if (parentBackgroundColor != null) { - backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor) - } else { - backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background) - } + if (note.author == loggedIn) { + backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background) - var isNew by remember { mutableStateOf(false) } + alignment = Arrangement.End + shape = ChatBubbleShapeMe + } else { + backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background) - LaunchedEffect(key1 = routeForLastRead) { - routeForLastRead?.let { - scope.launch(Dispatchers.IO) { - val lastTime = NotificationCache.load(it) + alignment = Arrangement.Start + shape = ChatBubbleShapeThem + } - val createdAt = note.createdAt() - if (createdAt != null) { - NotificationCache.markAsRead(it, createdAt) - isNew = createdAt > lastTime + val scope = rememberCoroutineScope() + + LaunchedEffect(key1 = routeForLastRead) { + routeForLastRead?.let { + scope.launch(Dispatchers.IO) { + val lastTime = NotificationCache.load(it) + + val createdAt = note.createdAt() + if (createdAt != null) { + NotificationCache.markAsRead(it, createdAt) + } } } } - } - Column() { - val modif = if (innerQuote) { - Modifier.padding(top = 10.dp, end = 5.dp) - } else { - Modifier - .fillMaxWidth(1f) - .padding( - start = 12.dp, - end = 12.dp, - top = 5.dp, - bottom = 5.dp - ) - } - - Row( - modifier = modif, - horizontalArrangement = alignment - ) { - var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } - val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f) + Column() { + val modif = remember { + if (innerQuote) { + Modifier.padding(top = 10.dp, end = 5.dp) + } else { + Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 5.dp, + bottom = 5.dp + ) + } + } Row( - horizontalArrangement = alignment, - modifier = modif2.onSizeChanged { - availableBubbleSize = it - } + modifier = modif, + horizontalArrangement = alignment ) { - Surface( - color = backgroundBubbleColor, - shape = shape, - modifier = Modifier - .combinedClickable( - onClick = { }, - onLongClick = { popupExpanded = true } - ) + var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) } + val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f) + + Row( + horizontalArrangement = alignment, + modifier = modif2.onSizeChanged { + availableBubbleSize = it + } ) { - var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - - Column( + Surface( + color = backgroundBubbleColor, + shape = shape, modifier = Modifier - .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) - .onSizeChanged { - bubbleSize = it - } + .combinedClickable( + onClick = { + if (noteEvent is ChannelCreateEvent) { + navController.navigate("Channel/${note.idHex}") + } + }, + onLongClick = { popupExpanded = true } + ) ) { - val authorState by note.author!!.live().metadata.observeAsState() - val author = authorState?.user!! + var bubbleSize by remember { mutableStateOf(IntSize.Zero) } - if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = alignment, - modifier = Modifier.padding(top = 5.dp) - ) { - RobohashAsyncImageProxy( - robot = author.pubkeyHex, - model = ResizeImage(author.profilePicture(), 25.dp), - contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(25.dp) - .height(25.dp) - .clip(shape = CircleShape) - .clickable(onClick = { - author.let { - navController.navigate("User/${it.pubkeyHex}") - } - }) - ) - - CreateClickableTextWithEmoji( - clickablePart = " ${author.toBestDisplayName()}", - suffix = "", - tags = author.info?.latestMetadata?.tags, - fontWeight = FontWeight.Bold, - overrideColor = MaterialTheme.colors.onBackground, - route = "User/${author.pubkeyHex}", - navController = navController - ) - } - } - - val replyTo = note.replyTo - if (!innerQuote && !replyTo.isNullOrEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - replyTo.toSet().mapIndexed { _, note -> - ChatroomMessageCompose( - note, - null, - innerQuote = true, - parentBackgroundColor = backgroundBubbleColor, - accountViewModel = accountViewModel, - navController = navController, - onWantsToReply = onWantsToReply - ) + Column( + modifier = Modifier + .padding(start = 10.dp, end = 5.dp, bottom = 5.dp) + .onSizeChanged { + bubbleSize = it } - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - val event = note.event - if (event is ChannelCreateEvent) { - val channelInfo = event.channelInfo() - val text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.created)} " + ( - channelInfo.name - ?: "" - ) + " ${stringResource(R.string.with_description_of)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture)} '" + ( - channelInfo.picture - ?: "" - ) + "'" - - CreateTextWithEmoji( - text = text, - tags = note.author?.info?.latestMetadata?.tags - ) - } else if (event is ChannelMetadataEvent) { - val channelInfo = event.channelInfo() - val text = note.author?.toBestDisplayName() - .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( - channelInfo.name - ?: "" - ) + "', ${stringResource(R.string.description_to)} '" + ( - channelInfo.about - ?: "" - ) + "', ${stringResource(R.string.and_picture_to)} '" + ( - channelInfo.picture - ?: "" - ) + "'" - - CreateTextWithEmoji( - text = text, - tags = note.author?.info?.latestMetadata?.tags + ) { + if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) { + DrawAuthorInfo( + baseNote, + alignment, + navController ) } else { - val eventContent = accountViewModel.decrypt(note) - - val canPreview = note.author == accountUser || - (note.author?.let { accountUser.isFollowingCached(it) } ?: true) || - !noteForReports.hasAnyReports() - - if (eventContent != null) { - TranslatableRichTextViewer( - eventContent, - canPreview, - Modifier.padding(top = 5.dp), - note.event?.tags(), - backgroundBubbleColor, - accountViewModel, - navController - ) - } else { - TranslatableRichTextViewer( - stringResource(R.string.could_not_decrypt_the_message), - true, - Modifier.padding(top = 5.dp), - note.event?.tags(), - backgroundBubbleColor, - accountViewModel, - navController - ) - } + Spacer(modifier = Modifier.height(5.dp)) } - } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .padding(top = 5.dp) - .then( - with(LocalDensity.current) { - Modifier.widthIn( - bubbleSize.width.toDp(), - availableBubbleSize.width.toDp() + val replyTo = note.replyTo + if (!innerQuote && !replyTo.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + replyTo.lastOrNull()?.let { note -> + ChatroomMessageCompose( + note, + null, + innerQuote = true, + parentBackgroundColor = backgroundBubbleColor, + accountViewModel = accountViewModel, + navController = navController, + onWantsToReply = onWantsToReply ) } - ) - ) { - Row() { - Text( - timeAgoShort(note.createdAt(), context), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 12.sp - ) - - RelayBadges(note) - - Spacer(modifier = Modifier.width(10.dp)) + } } - Row() { - LikeReaction(baseNote, grayTint, accountViewModel) - Spacer(modifier = Modifier.width(5.dp)) - ZapReaction(baseNote, grayTint, accountViewModel) - Spacer(modifier = Modifier.width(5.dp)) - ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) { - onWantsToReply(baseNote) + Row(verticalAlignment = Alignment.CenterVertically) { + when (noteEvent) { + is ChannelCreateEvent -> { + RenderCreateChannelNote(note) + } + + is ChannelMetadataEvent -> { + RenderChangeChannelMetadataNote(note) + } + + else -> { + RenderRegularTextNote( + note, + loggedIn, + isAcceptableAndCanPreview.second, + backgroundBubbleColor, + accountViewModel, + navController + ) + } } } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(top = 5.dp) + .then( + with(LocalDensity.current) { + Modifier.widthIn( + bubbleSize.width.toDp(), + availableBubbleSize.width.toDp() + ) + } + ) + ) { + StatusRow( + baseNote, + accountViewModel, + onWantsToReply + ) + } } } } - } - NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel) + NoteQuickActionMenu( + note, + popupExpanded, + { popupExpanded = false }, + accountViewModel + ) + } } } } } @Composable -private fun RelayBadges(baseNote: Note) { - val noteRelaysState by baseNote.live().relays.observeAsState() - val noteRelays = noteRelaysState?.note?.relays ?: emptySet() +private fun StatusRow( + baseNote: Note, + accountViewModel: AccountViewModel, + onWantsToReply: (Note) -> Unit +) { + val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + val time = remember { baseNote.createdAt() ?: 0 } - var expanded by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + ChatTimeAgo(time) + RelayBadges(baseNote) + Spacer(modifier = Modifier.width(10.dp)) + } - val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3) - - val uri = LocalUriHandler.current - - FlowRow(Modifier.padding(start = 10.dp)) { - relaysToDisplay.forEach { - val url = it.removePrefix("wss://").removePrefix("ws://") - Box( - Modifier - .size(15.dp) - .padding(1.dp) - ) { - RobohashFallbackAsyncImage( - robot = "https://$url/favicon.ico", - robotSize = 15.dp, - model = "https://$url/favicon.ico", - contentDescription = stringResource(id = R.string.relay_icon), - colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }), - modifier = Modifier - .fillMaxSize(1f) - .clip(shape = CircleShape) - .background(MaterialTheme.colors.background) - .clickable(onClick = { uri.openUri("https://$url") }) - ) - } - } - - if (noteRelays.size > 3 && !expanded) { - IconButton( - modifier = Modifier.then(Modifier.size(15.dp)), - onClick = { expanded = true } - ) { - Icon( - imageVector = Icons.Default.ChevronRight, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - } + Row(verticalAlignment = Alignment.CenterVertically) { + LikeReaction(baseNote, grayTint, accountViewModel) + Spacer(modifier = Modifier.width(5.dp)) + ZapReaction(baseNote, grayTint, accountViewModel) + Spacer(modifier = Modifier.width(5.dp)) + ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) { + onWantsToReply(baseNote) } } } + +@Composable +fun ChatTimeAgo(time: Long) { + val context = LocalContext.current + + var timeStr by remember { mutableStateOf("") } + + LaunchedEffect(key1 = time) { + withContext(Dispatchers.IO) { + timeStr = timeAgoShort(time, context = context) + } + } + + Text( + timeStr, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 12.sp + ) +} + +@Composable +private fun RenderRegularTextNote( + note: Note, + loggedIn: User, + canPreview: Boolean, + backgroundBubbleColor: Color, + accountViewModel: AccountViewModel, + navController: NavController +) { + val tags = remember { note.event?.tags() } + val eventContent = remember { accountViewModel.decrypt(note) } + val modifier = remember { Modifier.padding(top = 5.dp) } + + if (eventContent != null) { + TranslatableRichTextViewer( + eventContent, + canPreview, + modifier, + tags, + backgroundBubbleColor, + accountViewModel, + navController + ) + } else { + TranslatableRichTextViewer( + stringResource(R.string.could_not_decrypt_the_message), + true, + modifier, + tags, + backgroundBubbleColor, + accountViewModel, + navController + ) + } +} + +@Composable +private fun RenderChangeChannelMetadataNote( + note: Note +) { + val noteEvent = note.event as? ChannelMetadataEvent ?: return + + val channelInfo = noteEvent.channelInfo() + val text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.changed_chat_name_to)} '" + ( + channelInfo.name + ?: "" + ) + "', ${stringResource(R.string.description_to)} '" + ( + channelInfo.about + ?: "" + ) + "', ${stringResource(R.string.and_picture_to)} '" + ( + channelInfo.picture + ?: "" + ) + "'" + + CreateTextWithEmoji( + text = text, + tags = note.author?.info?.latestMetadata?.tags + ) +} + +@Composable +private fun RenderCreateChannelNote(note: Note) { + val noteEvent = note.event as? ChannelCreateEvent ?: return + val channelInfo = remember { noteEvent.channelInfo() } + + val text = note.author?.toBestDisplayName() + .toString() + " ${stringResource(R.string.created)} " + ( + channelInfo.name + ?: "" + ) + " ${stringResource(R.string.with_description_of)} '" + ( + channelInfo.about + ?: "" + ) + "', ${stringResource(R.string.and_picture)} '" + ( + channelInfo.picture + ?: "" + ) + "'" + + CreateTextWithEmoji( + text = text, + tags = note.author?.info?.latestMetadata?.tags + ) +} + +@Composable +private fun DrawAuthorInfo( + baseNote: Note, + alignment: Arrangement.Horizontal, + navController: NavController +) { + val userState by baseNote.author!!.live().metadata.observeAsState() + + val pubkeyHex = remember { baseNote.author?.pubkeyHex } ?: return + val route = remember { "User/$pubkeyHex" } + val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } + val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = alignment, + modifier = Modifier.padding(top = 5.dp) + ) { + RobohashAsyncImageProxy( + robot = pubkeyHex, + model = userProfilePicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = Modifier + .width(25.dp) + .height(25.dp) + .clip(shape = CircleShape) + .clickable(onClick = { + navController.navigate(route) + }) + ) + + CreateClickableTextWithEmoji( + clickablePart = " $userDisplayName", + suffix = "", + tags = userTags, + fontWeight = FontWeight.Bold, + overrideColor = MaterialTheme.colors.onBackground, + route = route, + navController = navController + ) + } +} + +data class RelayBadgesState( + val shouldDisplayExpandButton: Boolean, + val noteRelays: List, + val noteRelaysSimple: List +) + +@Composable +private fun RelayBadges(baseNote: Note) { + val noteRelaysState by baseNote.live().relays.observeAsState() + + val state: RelayBadgesState by remember(noteRelaysState) { + val newShouldDisplayExpandButton = (noteRelaysState?.note?.relays?.size ?: 0) > 3 + val noteRelays = noteRelaysState?.note?.relays?.toList() ?: emptyList() + val noteRelaysSimple = noteRelaysState?.note?.relays?.take(3)?.toList() ?: emptyList() + + mutableStateOf(RelayBadgesState(newShouldDisplayExpandButton, noteRelays, noteRelaysSimple)) + } + + var expanded by remember { mutableStateOf(false) } + + val relaysToDisplay by remember { + derivedStateOf { + if (expanded) state.noteRelays else state.noteRelaysSimple + } + } + + FlowRow(Modifier.padding(start = 10.dp)) { + relaysToDisplay.forEach { + RenderRelay(it) + } + } + + if (state.shouldDisplayExpandButton && !expanded) { + IconButton( + modifier = Modifier.then(Modifier.size(15.dp)), + onClick = { expanded = true } + ) { + Icon( + imageVector = Icons.Default.ChevronRight, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } +} + +@Composable +private fun RenderRelay(dirtyUrl: String) { + val uri = LocalUriHandler.current + val website = remember { + val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://") + "https://$cleanUrl" + } + val iconUrl = remember { + val cleanUrl = dirtyUrl.removePrefix("wss://").removePrefix("ws://") + "https://$cleanUrl/favicon.ico" + } + + val clickableModifier = remember { + Modifier + .size(15.dp) + .padding(1.dp) + .clickable(onClick = { uri.openUri(website) }) + } + + val colorFilter = remember { + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) }) + } + + val iconModifier = remember { + Modifier + .fillMaxSize(1f) + .clip(shape = CircleShape) + } + + Box( + modifier = clickableModifier + ) { + RobohashFallbackAsyncImage( + robot = iconUrl, + robotSize = 15.dp, + model = iconUrl, + contentDescription = stringResource(id = R.string.relay_icon), + colorFilter = colorFilter, + modifier = iconModifier.background(MaterialTheme.colors.background) + ) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index cc7723a6f..b6c279f1f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -196,13 +196,13 @@ fun NoteComposeInner( val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return val noteState by baseNote.live().metadata.observeAsState() - val note = remember(noteState) { noteState?.note } + val note = remember(noteState) { noteState?.note } ?: return val noteReportsState by baseNote.live().reports.observeAsState() val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return - val noteEvent = note?.event - val baseChannel = note?.channel() + val noteEvent = remember(noteState) { note.event } + val baseChannel = remember(noteState) { note.channel() } var popupExpanded by remember { mutableStateOf(false) } @@ -217,9 +217,11 @@ fun NoteComposeInner( isBoostedNote ) - note?.let { + note.let { NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel) } + } else if (account.isHidden(noteForReports.author!!)) { + // Does nothing } else { var showHiddenNote by remember { mutableStateOf(false) } var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } @@ -241,16 +243,14 @@ fun NoteComposeInner( } if (!isAcceptableAndCanPreview.first && !showHiddenNote) { - if (!account.isHidden(noteForReports.author!!)) { - HiddenNote( - account.getRelevantReports(noteForReports), - account.userProfile(), - modifier, - isBoostedNote, - navController, - onClick = { showHiddenNote = true } - ) - } + HiddenNote( + account.getRelevantReports(noteForReports), + account.userProfile(), + modifier, + isBoostedNote, + navController, + onClick = { showHiddenNote = true } + ) } else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) { ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) } else if (noteEvent is BadgeDefinitionEvent) { @@ -1941,7 +1941,7 @@ private fun RelayIconCompose(url: String) { .clickable(onClick = { uri.openUri("https://$url") }) } val colorFilter = remember { - ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) + ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) }) } Box(boxModifier) { @@ -2183,13 +2183,15 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, val actContext = LocalContext.current var reportDialogShowing by remember { mutableStateOf(false) } + val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() + var state by remember { mutableStateOf( DropDownParams(false, false, false, false) ) } - LaunchedEffect(key1 = note) { + LaunchedEffect(key1 = note, key2 = bookmarkState) { withContext(Dispatchers.IO) { state = DropDownParams( accountViewModel.isFollowing(note.author), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index 02369406b..73c75812c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -217,10 +217,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni VerticalDivider(primaryLight) NoteQuickActionItem( - icon = ImageVector.vectorResource(id = R.drawable.text_select_move_forward_character), - label = stringResource(R.string.quick_action_select) + icon = ImageVector.vectorResource(id = R.drawable.relays), + label = stringResource(R.string.broadcast) ) { - showSelectTextDialog = true + accountViewModel.broadcast(note) + // showSelectTextDialog = true onDismiss() } VerticalDivider(primaryLight) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt new file mode 100644 index 000000000..484fbdde2 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -0,0 +1,351 @@ +package com.vitorpamplona.amethyst.ui.note + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.CenterVertically +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.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavController +import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModel +import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer +import com.patrykandpatrick.vico.core.entry.composed.plus +import com.patrykandpatrick.vico.core.entry.entryOf +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.ReactionEvent +import com.vitorpamplona.amethyst.service.model.RepostEvent +import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.math.BigDecimal +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun UserReactionsRow(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController, onClick: () -> Unit) { + Row( + verticalAlignment = CenterVertically, + modifier = Modifier + .clickable(onClick = onClick) + .padding(10.dp) + ) { + Row(verticalAlignment = CenterVertically, modifier = Modifier.width(68.dp)) { + Text( + text = stringResource(id = R.string.today), + fontWeight = FontWeight.Bold + ) + + Icon( + imageVector = Icons.Default.ExpandMore, + null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserReplyReaction(model.replies[model.today]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserBoostReaction(model.boosts[model.today]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserLikeReaction(model.reactions[model.today]) + } + + Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { + UserZapReaction(model.zaps[model.today]) + } + } +} + +class UserReactionsViewModel : ViewModel() { + var user: User? = null + + var reactions by mutableStateOf>(emptyMap()) + var boosts by mutableStateOf>(emptyMap()) + var zaps by mutableStateOf>(emptyMap()) + var replies by mutableStateOf>(emptyMap()) + + var takenIntoAccount = setOf() + + val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() + val today = sdf.format(LocalDateTime.now()) + + var chartModel by mutableStateOf?>(null) + var axisLabels by mutableStateOf>(emptyList()) + + fun load(baseUser: User) { + user = baseUser + reactions = emptyMap() + boosts = emptyMap() + zaps = emptyMap() + replies = emptyMap() + takenIntoAccount = emptySet() + } + + fun formatDate(createAt: Long): String { + return sdf.format( + Instant.ofEpochSecond(createAt) + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + ) + } + + fun initializeSuspend() { + val currentUser = user?.pubkeyHex ?: return + + val reactions = mutableMapOf() + val boosts = mutableMapOf() + val zaps = mutableMapOf() + val replies = mutableMapOf() + val takenIntoAccount = mutableSetOf() + + LocalCache.notes.values.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + } + } + } + } + + this.takenIntoAccount = takenIntoAccount + this.reactions = reactions + this.replies = replies + this.zaps = zaps + this.boosts = boosts + + refreshChartModel() + } + + fun addToStatsSuspend(newNotes: Set) { + val currentUser = user?.pubkeyHex ?: return + + val reactions = this.reactions.toMutableMap() + val boosts = this.boosts.toMutableMap() + val zaps = this.zaps.toMutableMap() + val replies = this.replies.toMutableMap() + val takenIntoAccount = this.takenIntoAccount.toMutableSet() + var hasNewElements = false + + newNotes.forEach { + val noteEvent = it.event + if (noteEvent != null && !takenIntoAccount.contains(noteEvent.id())) { + if (noteEvent is ReactionEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + reactions[netDate] = (reactions[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is RepostEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + boosts[netDate] = (boosts[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is LnZapEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + zaps[netDate] = (zaps[netDate] ?: BigDecimal.ZERO) + (noteEvent.amount ?: BigDecimal.ZERO) + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } else if (noteEvent is TextNoteEvent) { + if (noteEvent.isTaggedUser(currentUser) && noteEvent.pubKey != currentUser) { + val netDate = formatDate(noteEvent.createdAt) + replies[netDate] = (replies[netDate] ?: 0) + 1 + takenIntoAccount.add(noteEvent.id()) + hasNewElements = true + } + } + } + } + + if (hasNewElements) { + this.takenIntoAccount = takenIntoAccount + this.reactions = reactions + this.replies = replies + this.zaps = zaps + this.boosts = boosts + + refreshChartModel() + } + } + + private fun refreshChartModel() { + val day = 24 * 60 * 60L + val now = LocalDateTime.now() + val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") + + val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } + + val listOfCountCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) }, + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) } + ) + + val listOfValueCurves = listOf( + dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) } + ) + + val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() + val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() + + this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) } + this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2) + } + + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + addToStatsSuspend(newNotes) + } + } + } + + override fun onCleared() { + collectorJob?.cancel() + super.onCleared() + } +} + +@Composable +fun UserReplyReaction( + replyCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_comment), + null, + modifier = Modifier.size(20.dp), + tint = Color.Cyan + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(replyCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserBoostReaction( + boostCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_retweeted), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(boostCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserLikeReaction( + likeCount: Int? +) { + Icon( + painter = painterResource(R.drawable.ic_liked), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + showCount(likeCount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} + +@Composable +fun UserZapReaction( + amount: BigDecimal? +) { + Icon( + imageVector = Icons.Default.Bolt, + contentDescription = stringResource(R.string.zaps), + modifier = Modifier.size(24.dp), + tint = BitcoinOrange + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + showAmount(amount), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) +} 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 d2cdff261..c6bcc52ab 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.widget.Toast import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -253,7 +254,11 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont val context = LocalContext.current.applicationContext - Column() { + Column( + Modifier.clickable { + navController.navigate("Channel/${baseChannel.idHex}") + } + ) { Column(modifier = Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { RobohashAsyncImageProxy( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt index 9046a1569..298955136 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NotificationScreen.kt @@ -1,29 +1,65 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController +import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis +import com.patrykandpatrick.vico.compose.axis.vertical.endAxis +import com.patrykandpatrick.vico.compose.axis.vertical.startAxis +import com.patrykandpatrick.vico.compose.chart.Chart +import com.patrykandpatrick.vico.compose.chart.line.lineChart +import com.patrykandpatrick.vico.compose.component.shape.shader.fromBrush +import com.patrykandpatrick.vico.compose.style.ProvideChartStyle +import com.patrykandpatrick.vico.core.DefaultAlpha +import com.patrykandpatrick.vico.core.axis.AxisPosition +import com.patrykandpatrick.vico.core.axis.formatter.AxisValueFormatter +import com.patrykandpatrick.vico.core.chart.composed.plus +import com.patrykandpatrick.vico.core.chart.line.LineChart +import com.patrykandpatrick.vico.core.chart.values.ChartValues +import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders +import com.patrykandpatrick.vico.core.entry.composed.plus import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.navigation.Route +import com.vitorpamplona.amethyst.ui.note.OneGiga +import com.vitorpamplona.amethyst.ui.note.OneKilo +import com.vitorpamplona.amethyst.ui.note.OneMega +import com.vitorpamplona.amethyst.ui.note.UserReactionsRow +import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.CardFeedView import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import java.math.BigDecimal +import java.math.RoundingMode +import kotlin.math.roundToInt @Composable fun NotificationScreen( notifFeedViewModel: NotificationViewModel, + userReactionsStatsModel: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController, scrollToTop: Boolean = false @@ -61,6 +97,7 @@ fun NotificationScreen( Column( modifier = Modifier.padding(vertical = 0.dp) ) { + SummaryBar(userReactionsStatsModel, accountViewModel, navController) CardFeedView( viewModel = notifFeedViewModel, accountViewModel = accountViewModel, @@ -72,3 +109,121 @@ fun NotificationScreen( } } } + +@Composable +fun SummaryBar(model: UserReactionsViewModel, accountViewModel: AccountViewModel, navController: NavController) { + var showChart by remember { + mutableStateOf(false) + } + + UserReactionsRow(model, accountViewModel, navController) { + showChart = !showChart + } + + val lineChartCount = + lineChart( + lines = listOf(Color.Cyan, Color.Green, Color.Red).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) + ) + ) + ) + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.Start + ) + + val lineChartZaps = + lineChart( + lines = listOf(BitcoinOrange).map { lineChartColor -> + LineChart.LineSpec( + lineColor = lineChartColor.toArgb(), + lineBackgroundShader = DynamicShaders.fromBrush( + Brush.verticalGradient( + listOf( + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_START), + lineChartColor.copy(DefaultAlpha.LINE_BACKGROUND_SHADER_END) + ) + ) + ) + ) + }, + targetVerticalAxisPosition = AxisPosition.Vertical.End + ) + + model.chartModel?.let { + if (showChart) { + Row( + modifier = Modifier + .padding(vertical = 10.dp, horizontal = 20.dp) + .clickable(onClick = { showChart = !showChart }) + ) { + ProvideChartStyle() { + Chart( + chart = remember(lineChartCount, lineChartZaps) { + lineChartCount.plus(lineChartZaps) + }, + model = it, + startAxis = startAxis( + valueFormatter = CountAxisValueFormatter() + ), + endAxis = endAxis( + valueFormatter = AmountAxisValueFormatter() + ), + bottomAxis = bottomAxis( + valueFormatter = LabelValueFormatter(model.axisLabels) + ) + ) + } + } + } + } + + Divider( + thickness = 0.25.dp + ) +} + +class LabelValueFormatter(val axisLabels: List) : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return axisLabels[value.roundToInt()] + } +} + +class CountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showCount(value.roundToInt()) + } +} + +class AmountAxisValueFormatter() : AxisValueFormatter { + override fun formatValue( + value: Float, + chartValues: ChartValues + ): String { + return showAmountAxis(value.toBigDecimal()) + } +} + +fun showAmountAxis(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= OneGiga -> "%.0fG".format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> "%.0fM".format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneKilo -> "%.0fk".format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> "%.0f".format(amount) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index d37de7787..320e913c0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -131,6 +131,8 @@ fun SearchScreen( } class SearchBarViewModel : ViewModel() { + var account: Account? = null + var searchValue by mutableStateOf("") val searchResults = mutableStateOf>(emptyList()) val searchResultsNotes = mutableStateOf>(emptyList()) @@ -156,7 +158,7 @@ class SearchBarViewModel : ViewModel() { } hashtagResults.value = findHashtags(searchValue) - searchResults.value = LocalCache.findUsersStartingWith(searchValue) + searchResults.value = LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed() searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue) } @@ -185,6 +187,7 @@ class SearchBarViewModel : ViewModel() { @Composable private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) { val searchBarViewModel: SearchBarViewModel = viewModel() + searchBarViewModel.account = accountViewModel.accountLiveData.value?.account val scope = rememberCoroutineScope() val listState = rememberLazyListState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 4ae9fef50..7dc3d86dc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -275,7 +275,7 @@ private fun RenderVideoOrPictureNote( Icon( imageVector = Icons.Default.MoreVert, null, - modifier = Modifier.size(15.dp), + modifier = Modifier.size(20.dp), tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 2d5f6b037..86d2c42c4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -284,6 +284,8 @@ "%1$s esemény keresése" Nyilvános üzenet hozzáadása + Adj hozzá egy privát üzenetet + A számlához adj hozzá egy üzenetet Köszönöm a kemény munkát! Létrehoz és Hozzáad @@ -334,6 +336,13 @@ Saját csomópontjaid (NIP-95) A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel + Tor/Orbot beállítása + Csatlakozás a saját Orbot beállításod alapján + + Lecsatlakozás a saját Orbot/Tor hálózatról? + Az adataid azonnal a hagyományos hálózaton lesz továbbítva + Igen + Nem Követek Lista @@ -374,4 +383,6 @@ npub, nevent vagy hex Létrehoz Csatlakozás + + Ma diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9de6c008b..b10e09631 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -402,4 +402,6 @@ npub, nevent or hex Create Join + + Today diff --git a/build.gradle b/build.gradle index 09111b578..4d3a662e0 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ buildscript { room_version = "2.4.3" accompanist_version = '0.30.0' coil_version = '2.3.0' + vico_version = '1.6.5' } dependencies { classpath 'com.google.gms:google-services:4.3.15'