Merge branch 'vitorpamplona:main' into main
This commit is contained in:
+8
-2
@@ -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(
|
||||||
|
|||||||
+155
@@ -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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user