Merge branch 'vitorpamplona:main' into main

This commit is contained in:
L
2023-05-20 20:38:19 +03:30
committed by GitHub
26 changed files with 1171 additions and 360 deletions
+8 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst" applicationId "com.vitorpamplona.amethyst"
minSdk 26 minSdk 26
targetSdk 33 targetSdk 33
versionCode 165 versionCode 172
versionName "0.49.2" versionName "0.50.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -177,6 +177,12 @@ dependencies {
playImplementation platform('com.google.firebase:firebase-bom:32.0.0') playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx' 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 // Automatic memory leak detection
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
@@ -593,18 +593,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create( val event = if (note is AddressableNote) {
"bookmark", BookmarkListEvent.create(
bookmarks?.taggedEvents() ?: emptyList(), "bookmark",
bookmarks?.taggedUsers() ?: emptyList(), bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(), bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex), bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(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) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
@@ -615,18 +630,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create( val event = if (note is AddressableNote) {
"bookmark", BookmarkListEvent.create(
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex), "bookmark",
bookmarks?.taggedUsers() ?: emptyList(), bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(), bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.plus(note.address) ?: listOf(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(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) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
@@ -637,18 +667,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create( val event = if (note is AddressableNote) {
"bookmark", BookmarkListEvent.create(
bookmarks?.taggedEvents() ?: emptyList(), "bookmark",
bookmarks?.taggedUsers() ?: emptyList(), bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(), bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(), bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(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) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
@@ -665,18 +710,33 @@ class Account(
val bookmarks = userProfile().latestBookmarkList val bookmarks = userProfile().latestBookmarkList
val event = BookmarkListEvent.create( val event = if (note is AddressableNote) {
"bookmark", BookmarkListEvent.create(
bookmarks?.taggedEvents()?.minus(note.idHex), "bookmark",
bookmarks?.taggedUsers() ?: emptyList(), bookmarks?.taggedEvents() ?: emptyList(),
bookmarks?.taggedAddresses() ?: emptyList(), bookmarks?.taggedUsers() ?: emptyList(),
bookmarks?.taggedAddresses()?.minus(note.address),
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(), bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(), bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
bookmarks?.privateTaggedAddresses(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) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
@@ -553,17 +553,20 @@ object LocalCache {
// Log.d("MT", "New Event ${event.content} ${event.id.toHex()}") // Log.d("MT", "New Event ${event.content} ${event.id.toHex()}")
val oldChannel = getOrCreateChannel(event.id) val oldChannel = getOrCreateChannel(event.id)
val author = getOrCreateUser(event.pubKey) 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) { if (event.createdAt <= oldChannel.updatedMetadataAt) {
return // older data, does nothing return // older data, does nothing
} }
if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) 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 (event.createdAt > oldChannel.updatedMetadataAt) {
if (oldChannel.creator == null || oldChannel.creator == author) { if (oldChannel.creator == null || oldChannel.creator == author) {
oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt) oldChannel.updateChannelInfo(author, event.channelInfo(), event.createdAt)
val note = getOrCreateNote(event.id)
oldChannel.addNote(note)
note.loadEvent(event, author, emptyList())
refreshObservers(note)
} }
} else { } else {
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}") // 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?) { fun consume(event: ChannelMessageEvent, relay: Relay?) {
@@ -95,7 +95,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
BadgeAwardEvent.kind BadgeAwardEvent.kind
), ),
tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)), tags = mapOf("p" to listOf(account.userProfile().pubkeyHex)),
limit = 400, limit = 4000,
since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList since = latestEOSEs.users[account.userProfile()]?.followList?.get(account.defaultNotificationFollowList)?.relayList
) )
) )
@@ -4,7 +4,7 @@ import java.util.regex.Pattern
object LnWithdrawalUtil { object LnWithdrawalUtil {
private val withdrawalPattern = Pattern.compile( private val withdrawalPattern = Pattern.compile(
"lnurl.*", "lnurl.+",
Pattern.CASE_INSENSITIVE Pattern.CASE_INSENSITIVE
) )
@@ -43,6 +43,7 @@ class TextNoteEvent(
} }
findHashtags(msg).forEach { findHashtags(msg).forEach {
tags.add(listOf("t", it)) tags.add(listOf("t", it))
tags.add(listOf("t", it.lowercase()))
} }
extraTags?.forEach { extraTags?.forEach {
tags.add(listOf("t", it)) tags.add(listOf("t", it))
@@ -70,7 +70,7 @@ object Client : RelayPool.Listener {
if (relay == null) { if (relay == null) {
RelayPool.send(signedEvent) RelayPool.send(signedEvent)
} else { } else {
val useConnectedRelayIfPresent = relays.filter { it.url == relay } val useConnectedRelayIfPresent = RelayPool.getRelays(relay)
if (useConnectedRelayIfPresent.isNotEmpty()) { if (useConnectedRelayIfPresent.isNotEmpty()) {
useConnectedRelayIfPresent.forEach { useConnectedRelayIfPresent.forEach {
@@ -103,7 +103,7 @@ object Client : RelayPool.Listener {
onConnected(relay) onConnected(relay)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
delay(10000) // waits for a reply delay(60000) // waits for a reply
relay.disconnect() relay.disconnect()
RelayPool.removeRelay(relay) RelayPool.removeRelay(relay)
@@ -30,6 +30,10 @@ object RelayPool : Relay.Listener {
return relays.firstOrNull() { it.url == url } return relays.firstOrNull() { it.url == url }
} }
fun getRelays(url: String): List<Relay> {
return relays.filter { it.url == url }
}
fun loadRelays(relayList: List<Relay>) { fun loadRelays(relayList: List<Relay>) {
if (!relayList.isNullOrEmpty()) { if (!relayList.isNullOrEmpty()) {
relayList.forEach { addRelay(it) } relayList.forEach { addRelay(it) }
@@ -75,6 +75,7 @@ import kotlinx.coroutines.withContext
@Composable @Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) { fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
val searchBarViewModel: SearchBarViewModel = viewModel() val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = account
Dialog( Dialog(
onDismissRequest = { onDismissRequest = {
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.CurrencyBitcoin
import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.ArrowForwardIos
import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner 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( Dialog(
onDismissRequest = { onClose() }, onDismissRequest = { onClose() },
properties = DialogProperties( properties = DialogProperties(
@@ -14,6 +14,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.service.FileHeader 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.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.isValidURL
@@ -188,6 +189,8 @@ open class NewPostViewModel : ViewModel() {
userSuggestions = emptyList() userSuggestions = emptyList()
userSuggestionAnchor = null userSuggestionAnchor = null
userSuggestionsMainMessage = null userSuggestionsMainMessage = null
NostrSearchEventOrUserDataSource.clear()
} }
open fun findUrlInMessage(): String? { open fun findUrlInMessage(): String? {
@@ -211,8 +214,10 @@ open class NewPostViewModel : ViewModel() {
userSuggestionAnchor = it.selection userSuggestionAnchor = it.selection
userSuggestionsMainMessage = true userSuggestionsMainMessage = true
if (lastWord.startsWith("@") && lastWord.length > 2) { 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 { } else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList() userSuggestions = emptyList()
} }
} }
@@ -225,8 +230,10 @@ open class NewPostViewModel : ViewModel() {
userSuggestionAnchor = it.selection userSuggestionAnchor = it.selection
userSuggestionsMainMessage = false userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) { 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 { } else {
NostrSearchEventOrUserDataSource.clear()
userSuggestions = emptyList() userSuggestions = emptyList()
} }
} }
@@ -37,7 +37,7 @@ fun RobohashAsyncImage(
robotSize.roundToPx() robotSize.roundToPx()
} }
val imageRequest = remember(size, robot) { val imageRequest = remember(robotSize, robot) {
Robohash.imageRequest( Robohash.imageRequest(
context, context,
robot, robot,
@@ -8,6 +8,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController 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.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter 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.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel 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.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -50,7 +54,8 @@ fun AppNavigation(
// Avoids creating ViewModels for performance reasons (up to 1 second delays) // Avoids creating ViewModels for performance reasons (up to 1 second delays)
val accountState by accountViewModel.accountLiveData.observeAsState() 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 HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account HomeConversationsFeedFilter.account = account
@@ -67,6 +72,16 @@ fun AppNavigation(
NotificationFeedFilter.account = account NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel() 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) { NavHost(navController, startDestination = Route.Home.route) {
Route.Video.let { route -> Route.Video.let { route ->
composable(route.route, route.arguments, content = { composable(route.route, route.arguments, content = {
@@ -135,6 +150,7 @@ fun AppNavigation(
NotificationScreen( NotificationScreen(
notifFeedViewModel = notifFeedViewModel, notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
navController = navController, navController = navController,
scrollToTop = scrollToTop scrollToTop = scrollToTop
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -23,6 +24,7 @@ import androidx.compose.material.Text
import androidx.compose.material.TopAppBar import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -337,7 +339,16 @@ fun SimpleTextSpinner(
modifier = modifier, modifier = modifier,
contentAlignment = Alignment.Center 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( Box(
modifier = Modifier modifier = Modifier
.matchParentSize() .matchParentSize()
@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@Composable @Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false) { fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) {
Column(modifier = modifier) { Column(modifier = modifier) {
Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) { Row(modifier = Modifier.padding(horizontal = if (!isQuote) 12.dp else 6.dp)) {
Column(modifier = Modifier.padding(start = if (!isQuote) 10.dp else 5.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 horizontalArrangement = Arrangement.Center
) { ) {
Text( 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), modifier = Modifier.padding(30.dp),
color = Color.Gray color = Color.Gray
) )
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -55,6 +56,7 @@ import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note 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.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent 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 com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
@@ -82,27 +85,54 @@ fun ChatroomMessageCompose(
navController: NavController, navController: NavController,
onWantsToReply: (Note) -> Unit onWantsToReply: (Note) -> Unit
) { ) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val accountState by accountViewModel.accountLiveData.observeAsState() 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 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 popupExpanded by remember { mutableStateOf(false) }
var showHiddenNote by remember { mutableStateOf(false) }
val context = LocalContext.current.applicationContext if (noteEvent == null) {
val scope = rememberCoroutineScope() BlankNote(
Modifier.combinedClickable(
onClick = { },
onLongClick = { popupExpanded = true }
)
)
if (note?.event == null) { note.let {
BlankNote(Modifier) NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { }
if (!account.isHidden(noteForReports.author!!)) { } 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( HiddenNote(
account.getRelevantReports(noteForReports), account.getRelevantReports(noteForReports),
account.userProfile(), account.userProfile(),
@@ -111,305 +141,436 @@ fun ChatroomMessageCompose(
navController, navController,
onClick = { showHiddenNote = true } 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 { } else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) val backgroundBubbleColor: Color
alignment = Arrangement.Start val alignment: Arrangement.Horizontal
shape = ChatBubbleShapeThem val shape: Shape
}
if (parentBackgroundColor != null) { if (note.author == loggedIn) {
backgroundBubbleColor = backgroundBubbleColor.compositeOver(parentBackgroundColor) backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
} else { .compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
backgroundBubbleColor = backgroundBubbleColor.compositeOver(MaterialTheme.colors.background)
}
var isNew by remember { mutableStateOf<Boolean>(false) } alignment = Arrangement.End
shape = ChatBubbleShapeMe
} else {
backgroundBubbleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
.compositeOver(parentBackgroundColor ?: MaterialTheme.colors.background)
LaunchedEffect(key1 = routeForLastRead) { alignment = Arrangement.Start
routeForLastRead?.let { shape = ChatBubbleShapeThem
scope.launch(Dispatchers.IO) { }
val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt() val scope = rememberCoroutineScope()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt) LaunchedEffect(key1 = routeForLastRead) {
isNew = createdAt > lastTime routeForLastRead?.let {
scope.launch(Dispatchers.IO) {
val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt()
if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
}
} }
} }
} }
}
Column() { Column() {
val modif = if (innerQuote) { val modif = remember {
Modifier.padding(top = 10.dp, end = 5.dp) if (innerQuote) {
} else { Modifier.padding(top = 10.dp, end = 5.dp)
Modifier } else {
.fillMaxWidth(1f) Modifier
.padding( .fillMaxWidth(1f)
start = 12.dp, .padding(
end = 12.dp, start = 12.dp,
top = 5.dp, end = 12.dp,
bottom = 5.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)
Row( Row(
horizontalArrangement = alignment, modifier = modif,
modifier = modif2.onSizeChanged { horizontalArrangement = alignment
availableBubbleSize = it
}
) { ) {
Surface( var availableBubbleSize by remember { mutableStateOf(IntSize.Zero) }
color = backgroundBubbleColor, val modif2 = if (innerQuote) Modifier else Modifier.fillMaxWidth(0.85f)
shape = shape,
modifier = Modifier Row(
.combinedClickable( horizontalArrangement = alignment,
onClick = { }, modifier = modif2.onSizeChanged {
onLongClick = { popupExpanded = true } availableBubbleSize = it
) }
) { ) {
var bubbleSize by remember { mutableStateOf(IntSize.Zero) } Surface(
color = backgroundBubbleColor,
Column( shape = shape,
modifier = Modifier modifier = Modifier
.padding(start = 10.dp, end = 5.dp, bottom = 5.dp) .combinedClickable(
.onSizeChanged { onClick = {
bubbleSize = it if (noteEvent is ChannelCreateEvent) {
} navController.navigate("Channel/${note.idHex}")
}
},
onLongClick = { popupExpanded = true }
)
) { ) {
val authorState by note.author!!.live().metadata.observeAsState() var bubbleSize by remember { mutableStateOf(IntSize.Zero) }
val author = authorState?.user!!
if (innerQuote || author != accountUser && note.event is ChannelMessageEvent) { Column(
Row( modifier = Modifier
verticalAlignment = Alignment.CenterVertically, .padding(start = 10.dp, end = 5.dp, bottom = 5.dp)
horizontalArrangement = alignment, .onSizeChanged {
modifier = Modifier.padding(top = 5.dp) bubbleSize = it
) {
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
)
} }
} ) {
} if ((innerQuote || note.author != loggedIn) && noteEvent is ChannelMessageEvent) {
DrawAuthorInfo(
Row(verticalAlignment = Alignment.CenterVertically) { baseNote,
val event = note.event alignment,
if (event is ChannelCreateEvent) { navController
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
) )
} else { } else {
val eventContent = accountViewModel.decrypt(note) Spacer(modifier = Modifier.height(5.dp))
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
)
}
} }
}
Row( val replyTo = note.replyTo
verticalAlignment = Alignment.CenterVertically, if (!innerQuote && !replyTo.isNullOrEmpty()) {
horizontalArrangement = Arrangement.SpaceBetween, Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier replyTo.lastOrNull()?.let { note ->
.padding(top = 5.dp) ChatroomMessageCompose(
.then( note,
with(LocalDensity.current) { null,
Modifier.widthIn( innerQuote = true,
bubbleSize.width.toDp(), parentBackgroundColor = backgroundBubbleColor,
availableBubbleSize.width.toDp() 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() { Row(verticalAlignment = Alignment.CenterVertically) {
LikeReaction(baseNote, grayTint, accountViewModel) when (noteEvent) {
Spacer(modifier = Modifier.width(5.dp)) is ChannelCreateEvent -> {
ZapReaction(baseNote, grayTint, accountViewModel) RenderCreateChannelNote(note)
Spacer(modifier = Modifier.width(5.dp)) }
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote) 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 @Composable
private fun RelayBadges(baseNote: Note) { private fun StatusRow(
val noteRelaysState by baseNote.live().relays.observeAsState() baseNote: Note,
val noteRelays = noteRelaysState?.note?.relays ?: emptySet() 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) Row(verticalAlignment = Alignment.CenterVertically) {
LikeReaction(baseNote, grayTint, accountViewModel)
val uri = LocalUriHandler.current Spacer(modifier = Modifier.width(5.dp))
ZapReaction(baseNote, grayTint, accountViewModel)
FlowRow(Modifier.padding(start = 10.dp)) { Spacer(modifier = Modifier.width(5.dp))
relaysToDisplay.forEach { ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false, iconSize = 16.dp) {
val url = it.removePrefix("wss://").removePrefix("ws://") onWantsToReply(baseNote)
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)
)
}
} }
} }
} }
@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<String>,
val noteRelaysSimple: List<String>
)
@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)
)
}
}
@@ -196,13 +196,13 @@ fun NoteComposeInner(
val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return val loggedIn = remember(accountState) { accountState?.account?.userProfile() } ?: return
val noteState by baseNote.live().metadata.observeAsState() 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 noteReportsState by baseNote.live().reports.observeAsState()
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
val noteEvent = note?.event val noteEvent = remember(noteState) { note.event }
val baseChannel = note?.channel() val baseChannel = remember(noteState) { note.channel() }
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
@@ -217,9 +217,11 @@ fun NoteComposeInner(
isBoostedNote isBoostedNote
) )
note?.let { note.let {
NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel) NoteQuickActionMenu(it, popupExpanded, { popupExpanded = false }, accountViewModel)
} }
} else if (account.isHidden(noteForReports.author!!)) {
// Does nothing
} else { } else {
var showHiddenNote by remember { mutableStateOf(false) } var showHiddenNote by remember { mutableStateOf(false) }
var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) } var isAcceptableAndCanPreview by remember { mutableStateOf(Pair(true, true)) }
@@ -241,16 +243,14 @@ fun NoteComposeInner(
} }
if (!isAcceptableAndCanPreview.first && !showHiddenNote) { if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) { HiddenNote(
HiddenNote( account.getRelevantReports(noteForReports),
account.getRelevantReports(noteForReports), account.userProfile(),
account.userProfile(), modifier,
modifier, isBoostedNote,
isBoostedNote, navController,
navController, onClick = { showHiddenNote = true }
onClick = { showHiddenNote = true } )
)
}
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) { } else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController) ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) { } else if (noteEvent is BadgeDefinitionEvent) {
@@ -1941,7 +1941,7 @@ private fun RelayIconCompose(url: String) {
.clickable(onClick = { uri.openUri("https://$url") }) .clickable(onClick = { uri.openUri("https://$url") })
} }
val colorFilter = remember { val colorFilter = remember {
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0.5f) })
} }
Box(boxModifier) { Box(boxModifier) {
@@ -2183,13 +2183,15 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
val actContext = LocalContext.current val actContext = LocalContext.current
var reportDialogShowing by remember { mutableStateOf(false) } var reportDialogShowing by remember { mutableStateOf(false) }
val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState()
var state by remember { var state by remember {
mutableStateOf<DropDownParams>( mutableStateOf<DropDownParams>(
DropDownParams(false, false, false, false) DropDownParams(false, false, false, false)
) )
} }
LaunchedEffect(key1 = note) { LaunchedEffect(key1 = note, key2 = bookmarkState) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
state = DropDownParams( state = DropDownParams(
accountViewModel.isFollowing(note.author), accountViewModel.isFollowing(note.author),
@@ -217,10 +217,11 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni
VerticalDivider(primaryLight) VerticalDivider(primaryLight)
NoteQuickActionItem( NoteQuickActionItem(
icon = ImageVector.vectorResource(id = R.drawable.text_select_move_forward_character), icon = ImageVector.vectorResource(id = R.drawable.relays),
label = stringResource(R.string.quick_action_select) label = stringResource(R.string.broadcast)
) { ) {
showSelectTextDialog = true accountViewModel.broadcast(note)
// showSelectTextDialog = true
onDismiss() onDismiss()
} }
VerticalDivider(primaryLight) VerticalDivider(primaryLight)
@@ -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<Map<String, Int>>(emptyMap())
var boosts by mutableStateOf<Map<String, Int>>(emptyMap())
var zaps by mutableStateOf<Map<String, BigDecimal>>(emptyMap())
var replies by mutableStateOf<Map<String, Int>>(emptyMap())
var takenIntoAccount = setOf<HexKey>()
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val today = sdf.format(LocalDateTime.now())
var chartModel by mutableStateOf<ComposedChartEntryModel<ChartEntryModel>?>(null)
var axisLabels by mutableStateOf<List<String>>(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<String, Int>()
val boosts = mutableMapOf<String, Int>()
val zaps = mutableMapOf<String, BigDecimal>()
val replies = mutableMapOf<String, Int>()
val takenIntoAccount = mutableSetOf<HexKey>()
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<Note>) {
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
)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -253,7 +254,11 @@ fun ChannelHeader(baseChannel: Channel, account: Account, navController: NavCont
val context = LocalContext.current.applicationContext val context = LocalContext.current.applicationContext
Column() { Column(
Modifier.clickable {
navController.navigate("Channel/${baseChannel.idHex}")
}
) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
RobohashAsyncImageProxy( RobohashAsyncImageProxy(
@@ -1,29 +1,65 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.Divider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.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.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController 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.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route 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.CardFeedView
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys 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 @Composable
fun NotificationScreen( fun NotificationScreen(
notifFeedViewModel: NotificationViewModel, notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
navController: NavController, navController: NavController,
scrollToTop: Boolean = false scrollToTop: Boolean = false
@@ -61,6 +97,7 @@ fun NotificationScreen(
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
SummaryBar(userReactionsStatsModel, accountViewModel, navController)
CardFeedView( CardFeedView(
viewModel = notifFeedViewModel, viewModel = notifFeedViewModel,
accountViewModel = accountViewModel, 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<String>) : AxisValueFormatter<AxisPosition.Horizontal.Bottom> {
override fun formatValue(
value: Float,
chartValues: ChartValues
): String {
return axisLabels[value.roundToInt()]
}
}
class CountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.Start> {
override fun formatValue(
value: Float,
chartValues: ChartValues
): String {
return showCount(value.roundToInt())
}
}
class AmountAxisValueFormatter() : AxisValueFormatter<AxisPosition.Vertical.End> {
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)
}
}
@@ -131,6 +131,8 @@ fun SearchScreen(
} }
class SearchBarViewModel : ViewModel() { class SearchBarViewModel : ViewModel() {
var account: Account? = null
var searchValue by mutableStateOf("") var searchValue by mutableStateOf("")
val searchResults = mutableStateOf<List<User>>(emptyList()) val searchResults = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList()) val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
@@ -156,7 +158,7 @@ class SearchBarViewModel : ViewModel() {
} }
hashtagResults.value = findHashtags(searchValue) 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() searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue) searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
} }
@@ -185,6 +187,7 @@ class SearchBarViewModel : ViewModel() {
@Composable @Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) { private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
val searchBarViewModel: SearchBarViewModel = viewModel() val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.accountLiveData.value?.account
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -275,7 +275,7 @@ private fun RenderVideoOrPictureNote(
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
null, null,
modifier = Modifier.size(15.dp), modifier = Modifier.size(20.dp),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
) )
+11
View File
@@ -284,6 +284,8 @@
<string name="looking_for_event">"%1$s esemény keresése"</string> <string name="looking_for_event">"%1$s esemény keresése"</string>
<string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string> <string name="custom_zaps_add_a_message">Nyilvános üzenet hozzáadása</string>
<string name="custom_zaps_add_a_message_private">Adj hozzá egy privát üzenetet</string>
<string name="custom_zaps_add_a_message_nonzap">A számlához adj hozzá egy üzenetet</string>
<string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string> <string name="custom_zaps_add_a_message_example">Köszönöm a kemény munkát!</string>
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string> <string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
@@ -334,6 +336,13 @@
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string> <string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">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</string> <string name="upload_server_relays_nip95_explainer">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</string>
<string name="connect_via_tor_short">Tor/Orbot beállítása</string>
<string name="connect_via_tor">Csatlakozás a saját Orbot beállításod alapján</string>
<string name="do_you_really_want_to_disable_tor_title">Lecsatlakozás a saját Orbot/Tor hálózatról?</string>
<string name="do_you_really_want_to_disable_tor_text">Az adataid azonnal a hagyományos hálózaton lesz továbbítva</string>
<string name="yes">Igen</string>
<string name="no">Nem</string>
<string name="follow_list_selection">Követek Lista</string> <string name="follow_list_selection">Követek Lista</string>
@@ -374,4 +383,6 @@
<string name="channel_list_user_or_group_id_demo">npub, nevent vagy hex</string> <string name="channel_list_user_or_group_id_demo">npub, nevent vagy hex</string>
<string name="channel_list_create_channel">Létrehoz</string> <string name="channel_list_create_channel">Létrehoz</string>
<string name="channel_list_join_channel">Csatlakozás</string> <string name="channel_list_join_channel">Csatlakozás</string>
<string name="today">Ma</string>
</resources> </resources>
+2
View File
@@ -402,4 +402,6 @@
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string> <string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
<string name="channel_list_create_channel">Create</string> <string name="channel_list_create_channel">Create</string>
<string name="channel_list_join_channel">Join</string> <string name="channel_list_join_channel">Join</string>
<string name="today">Today</string>
</resources> </resources>
+1
View File
@@ -7,6 +7,7 @@ buildscript {
room_version = "2.4.3" room_version = "2.4.3"
accompanist_version = '0.30.0' accompanist_version = '0.30.0'
coil_version = '2.3.0' coil_version = '2.3.0'
vico_version = '1.6.5'
} }
dependencies { dependencies {
classpath 'com.google.gms:google-services:4.3.15' classpath 'com.google.gms:google-services:4.3.15'