Compare commits

..
25 Commits
Author SHA1 Message Date
Vitor Pamplona f40b6c394c v0.46.2 2023-05-14 17:22:22 -04:00
Vitor Pamplona f84501e40f Improvements to Poll Caching system: Fixes https://github.com/vitorpamplona/amethyst/issues/406 2023-05-14 17:03:46 -04:00
Vitor Pamplona 6842d12b39 Turn FollowList into a ViewModel and a LocalCache LiveData into a SharedFlow (it was killing some coroutines that were supposed to finish) 2023-05-13 12:49:48 -04:00
Vitor Pamplona 3e69b81d81 Moves aspect ratio to the appropriate composable 2023-05-13 12:46:57 -04:00
Vitor Pamplona ea4e3e75d6 Formatting 2023-05-13 12:46:34 -04:00
Vitor Pamplona 5db04ef2b0 Allows the RichTextViewer to render before resolving urls. 2023-05-13 12:46:11 -04:00
Vitor Pamplona b51186de30 updated secp lib 2023-05-12 17:18:56 -04:00
Vitor Pamplona 8fc0f5a8b8 Removing shadowed variable 2023-05-12 16:50:26 -04:00
Vitor Pamplona 69744db53b Merge remote-tracking branch 'origin/HEAD' 2023-05-12 14:17:31 -04:00
Vitor Pamplona db9a0ebfa1 Protects against exceptions when creating keys with invalid bytearrays. 2023-05-12 14:13:08 -04:00
Vitor PamplonaandGitHub 38ebeded1c Merge pull request #402 from ZsZolee/main
TOR Hungarian translation
2023-05-12 13:40:41 -04:00
Vitor PamplonaandGitHub 2b8fafc123 Merge pull request #403 from judemont/main
French translation for Android description.
2023-05-12 13:40:24 -04:00
Vitor Pamplona fc2408b526 Correcting NPE from failing to load the thumbnail 2023-05-12 13:32:08 -04:00
JdM 6723806534 French translation for android description. 2023-05-12 17:26:11 +02:00
ZoltanandGitHub 93c3a49ecf TOR Hungarian translation 2023-05-12 14:04:52 +02:00
Vitor Pamplona 66b84c0dea 0.46.1 2023-05-11 18:30:18 -04:00
Vitor Pamplona a7063042fb Formatting 2023-05-11 18:15:56 -04:00
Vitor Pamplona aa60367c72 Doesn't update if it returned the same reference for the list. 2023-05-11 18:15:48 -04:00
Vitor Pamplona 19a58bb669 correctly refreshes the screen 2023-05-11 17:39:31 -04:00
Vitor Pamplona 55aafffb37 Remove logs 2023-05-11 17:39:21 -04:00
Vitor Pamplona 9b92dfca5e Isolating state changes in the profile page. 2023-05-11 17:31:32 -04:00
Vitor Pamplona 3ae75a3333 Adds FAB column to create new channels and join channels/users 2023-05-11 16:42:05 -04:00
Vitor Pamplona 9ebb3fbefc Improving reply screen to clarify which users will be notified. 2023-05-11 15:26:09 -04:00
Vitor Pamplona 96d66646b4 Displaying lists as Notes 2023-05-11 14:39:05 -04:00
Vitor Pamplona f202422ff1 hasValidSignature crashes in some weirdly formed events. 2023-05-11 10:12:01 -04:00
39 changed files with 1177 additions and 489 deletions
+3 -3
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 154
versionName "0.46.0"
versionCode 156
versionName "0.46.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -115,7 +115,7 @@ dependencies {
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
// Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -877,23 +877,28 @@ class Account(
null
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
try {
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) {
Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
)
if (result == null) {
Log.w(
"Private ZAP Decrypt",
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
)
}
result
} else {
null
}
result
} else {
null
}
} else {
} catch (e: Exception) {
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
null
}
}
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.Amethyst
@@ -11,6 +10,8 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
import java.io.File
@@ -969,16 +970,16 @@ object LocalCache {
}
}
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
class LocalCacheLiveData {
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
// Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes ->
if (hasActiveObservers()) {
postValue(bundledNewNotes)
}
_newEventBundles.emit(bundledNewNotes)
}
}
}
@@ -411,9 +411,9 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) {
if (hasActiveObservers()) {
postValue(NoteState(note))
}
// if (hasObservers()) {
postValue(NoteState(note))
// }
}
fun invalidateData() {
@@ -11,7 +11,6 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun loadUserProfile(user: User?) {
this.user = user
resetFilters()
}
fun createUserInfoFilter() = user?.let {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
import com.google.gson.*
import com.google.gson.annotations.SerializedName
import com.vitorpamplona.amethyst.model.HexKey
@@ -110,11 +111,12 @@ open class Event(
}
override fun hasValidSignature(): Boolean {
if (!id.contentEquals(generateId())) {
return false
return try {
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
} catch (e: Exception) {
Log.e("Event", "Fail checking if event $id has a valid signature", e)
false
}
return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
}
private fun generateId(): String {
@@ -0,0 +1,350 @@
package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
val searchBarViewModel: SearchBarViewModel = viewModel()
Dialog(
onDismissRequest = {
NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clean()
onClose()
},
properties = DialogProperties(
dismissOnClickOutside = false
)
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
searchBarViewModel.clean()
NostrSearchEventOrUserDataSource.clear()
onClose()
})
Text(
text = stringResource(R.string.channel_list_join_conversation),
fontWeight = FontWeight.Bold
)
Text(
text = "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, account, navController)
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RenderSeach(
searchBarViewModel: SearchBarViewModel,
account: Account,
navController: NavController
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onlineSearch = NostrSearchEventOrUserDataSource
val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries.
val searchTextChanges = remember {
Channel<String>(Channel.CONFLATED)
}
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData()
}
}
}
}
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges.receiveAsFlow()
.filter { it.isNotBlank() }
.distinctUntilChanged()
.debounce(300)
.collectLatest {
if (it.length >= 2) {
onlineSearch.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) {
listState.animateScrollToItem(0)
}
}
}
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Join Start")
NostrSearchEventOrUserDataSource.start()
searchBarViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Join Stop")
NostrSearchEventOrUserDataSource.clear()
NostrSearchEventOrUserDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
// LAST ROW
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
value = searchBarViewModel.searchValue,
onValueChange = {
searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
},
leadingIcon = {
Icon(
painter = painterResource(R.drawable.ic_search),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color.Unspecified
)
},
modifier = Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester)
.onFocusChanged {
if (it.isFocused) {
keyboardController?.show()
}
},
placeholder = {
Text(
text = stringResource(R.string.channel_list_user_or_group_id_demo),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
trailingIcon = {
if (searchBarViewModel.isTrailingIconVisible) {
IconButton(
onClick = {
searchBarViewModel.clean()
onlineSearch.clear()
}
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = stringResource(R.string.clear)
)
}
}
}
)
}
if (searchBarViewModel.searchValue.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResults.value,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
item,
account = account,
navController = navController
)
}
itemsIndexed(
searchBarViewModel.searchResultsChannels.value,
key = { _, item -> "c" + item.idHex }
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { navController.navigate("Channel/${item.idHex}") }
)
}
}
}
}
}
@Composable
fun UserComposeForChat(
baseUser: User,
account: Account,
navController: NavController
) {
Column(
modifier =
Modifier.clickable(
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
val baseUserState by baseUser.live().metadata.observeAsState()
val user = baseUserState?.user ?: return
Text(
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.util.Log
import android.util.Size
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -47,6 +49,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -58,9 +61,8 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
@@ -81,6 +83,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
@@ -145,10 +148,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.verticalScroll(scroolState)
) {
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "") {
postViewModel.removeFromReplyList(it)
}
Notifying(postViewModel.mentions) {
postViewModel.removeFromReplyList(it)
}
OutlinedTextField(
@@ -219,6 +220,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
},
onCancel = {
postViewModel.contentToAddUrl = null
},
onError = {
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
}
)
}
@@ -345,6 +351,47 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
val mentions = baseMentions?.toSet()
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
if (!mentions.isNullOrEmpty()) {
Text(
stringResource(R.string.reply_notify),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
mentions.forEachIndexed { idx, user ->
val innerUserState by user.live().metadata.observeAsState()
val innerUser = innerUserState?.user
innerUser?.let { myUser ->
Spacer(modifier = Modifier.width(5.dp))
Button(
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
),
onClick = {
onClick(myUser)
}
) {
Text(
"${myUser.toBestDisplayName()}",
color = Color.White,
textAlign = TextAlign.Center
)
}
}
}
}
}
}
@Composable
private fun AddPollButton(
isPollActive: Boolean,
@@ -601,7 +648,8 @@ fun ImageVideoDescription(
uri: Uri,
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable) -> Unit,
onCancel: () -> Unit
onCancel: () -> Unit,
onError: (String) -> Unit
) {
val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: ""
@@ -709,7 +757,12 @@ fun ImageVideoDescription(
LaunchedEffect(key1 = uri) {
scope.launch(Dispatchers.IO) {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
try {
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
} catch (e: Exception) {
onError("Unable to load file")
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
}
}
}
@@ -198,8 +198,8 @@ open class NewPostViewModel : ViewModel() {
}
}
open fun removeFromReplyList(it: User) {
mentions = mentions?.minus(it)
open fun removeFromReplyList(userToRemove: User) {
mentions = mentions?.filter { it != userToRemove }
}
open fun updateMessage(it: TextFieldValue) {
@@ -6,69 +6,78 @@ import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.runtime.*
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.NewPollView
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
@Composable
fun FabColumn(account: Account) {
fun ChannelFabColumn(account: Account, navController: NavController) {
var isOpen by remember {
mutableStateOf(false)
}
var wantsToPoll by remember {
var wantsToJoinChannelOrUser by remember {
mutableStateOf(false)
}
var wantsToPost by remember {
var wantsToCreateChannel by remember {
mutableStateOf(false)
}
if (wantsToCreateChannel) {
NewChannelView({ wantsToCreateChannel = false }, account = account)
}
if (wantsToJoinChannelOrUser) {
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController)
}
Column() {
if (isOpen) {
OutlinedButton(
onClick = {
wantsToPoll = true
isOpen = false
},
modifier = Modifier.size(45.dp),
onClick = { wantsToJoinChannelOrUser = true; isOpen = false },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
contentPadding = PaddingValues(bottom = 3.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_poll),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
Text(
text = stringResource(R.string.channel_list_join_channel),
color = Color.White,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(20.dp))
OutlinedButton(
onClick = {
wantsToPost = true
isOpen = false
},
modifier = Modifier.size(45.dp),
onClick = { wantsToCreateChannel = true; isOpen = false },
modifier = Modifier.size(55.dp),
shape = CircleShape,
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
contentPadding = PaddingValues(0.dp)
contentPadding = PaddingValues(bottom = 3.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_lists),
null,
modifier = Modifier.size(26.dp),
tint = Color.White
Text(
text = stringResource(R.string.channel_list_create_channel),
color = Color.White,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(20.dp))
}
OutlinedButton(
onClick = { isOpen = !isOpen },
modifier = Modifier.size(55.dp),
@@ -77,19 +86,11 @@ fun FabColumn(account: Account) {
contentPadding = PaddingValues(0.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
null,
imageVector = Icons.Outlined.Add,
contentDescription = stringResource(R.string.new_channel),
modifier = Modifier.size(26.dp),
tint = Color.White
)
}
}
if (wantsToPost) {
// NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
}
if (wantsToPoll) {
NewPollView({ wantsToPoll = false }, account = account)
}
}
@@ -8,8 +8,8 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* This class is designed to have a waiting time between two calls of invalidate
@@ -54,23 +54,26 @@ class BundledInsert<T>(
val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
private var onlyOneInBlock = AtomicBoolean()
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
atomicSet.updateAndGet() {
it + newObject
}
private var queue = LinkedBlockingQueue<T>()
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
return
}
val scope = CoroutineScope(Job() + dispatcher)
scope.launch {
scope.launch(Dispatchers.IO) {
try {
onUpdate(atomicSet.getAndSet(emptySet()))
val mySet = mutableSetOf<T>()
queue.drainTo(mySet)
onUpdate(mySet)
delay(delay)
onUpdate(atomicSet.getAndSet(emptySet()))
val mySet2 = mutableSetOf<T>()
queue.drainTo(mySet2)
onUpdate(mySet2)
} finally {
withContext(NonCancellable) {
onlyOneInBlock.set(false)
@@ -44,11 +44,13 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -106,11 +108,12 @@ fun RichTextViewer(
class RichTextViewerState(
val content: String,
val urlSet: LinkedHashSet<String>,
val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent>
)
@OptIn(ExperimentalTime::class)
@Composable
private fun RenderRegular(
content: String,
@@ -121,11 +124,13 @@ private fun RenderRegular(
navController: NavController
) {
var processedState by remember {
mutableStateOf<RichTextViewerState?>(null)
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl ->
@@ -140,7 +145,9 @@ private fun RenderRegular(
}.associateBy { it.url }
val imageList = imagesForPager.values.toList()
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
if (urlSet.isNotEmpty()) {
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
}
}
}
@@ -271,10 +271,6 @@ private fun UrlImageView(
mutableStateOf<Boolean?>(null)
}
val ratio = remember {
aspectRatio(content.dim)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
@@ -284,10 +280,10 @@ private fun UrlImageView(
}
BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also {
if (ratio != null) {
it.aspectRatio(ratio, maxHeight.isFinite)
}
val myModifier = mainImageModifier.run {
aspectRatio(content.dim)?.let { ratio ->
this.aspectRatio(ratio, maxHeight.isFinite)
} ?: this
}
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
@@ -48,7 +48,15 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
val event = note.event
if (event is BaseTextNoteEvent) {
return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true)
val isAuthoredPostCited = event.findCitations().any {
LocalCache.notes[it]?.author === author || LocalCache.addressables[it]?.author === author
}
return isAuthoredPostCited ||
(
event.citedUsers().contains(author.pubkeyHex) ||
note.replyTo?.any { it.author === author } == true
)
}
if (event is ReactionEvent) {
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
Route.Home,
@@ -136,9 +135,10 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
val scope = rememberCoroutineScope()
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
Icon(
painter = painterResource(id = route.icon),
@@ -150,15 +150,10 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val notifState = NotificationCache.live.observeAsState()
val notif = notifState.value ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = notif) {
scope.launch(Dispatchers.IO) {
@@ -166,9 +161,11 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
}
}
LaunchedEffect(key1 = db) {
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
hasNewItems = route.hasNewItems(account, notif.cache, db)
LocalCache.live.newEventBundles.collect {
hasNewItems = route.hasNewItems(account, notif.cache, it)
}
}
}
@@ -24,7 +24,6 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -40,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.Coil
@@ -71,43 +72,45 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)?.substringBefore("?")) {
// Route.Profile.route -> TopBarWithBackButton(navController)
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel)
}
}
@Composable
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
account.changeDefaultStoriesFollowList(listName)
}
}
}
@Composable
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName ->
account.changeDefaultHomeFollowList(listName)
}
}
}
@Composable
fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
account.changeDefaultNotificationFollowList(listName)
}
}
@@ -237,30 +240,19 @@ private fun LoggedInUserPictureDrawer(
}
@Composable
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
var followLists by remember { mutableStateOf(defaultOptions) }
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
val followLists = remember(followListsModel.followLists) {
(defaultOptions + followListsModel.followLists)
}
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag())
} else {
null
}
}.sortedBy { it.second }
val followNames = remember(followLists) {
derivedStateOf {
followLists.map { it.second }
}
}
@@ -273,6 +265,62 @@ fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange:
)
}
class FollowListViewModel : ViewModel() {
var followLists by mutableStateOf<List<Pair<String, String>>>(emptyList())
var account: Account? = null
fun load(account: Account?) {
this.account = account
refresh()
}
fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
refreshFollows()
}
}
private suspend fun refreshFollows() {
val myAccount = account ?: return
val newFollowLists = LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent)
// Has to have an list
if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag())
} else {
null
}
}.sortedBy { it.second }
withContext(Dispatchers.Main) {
if (followLists != newFollowLists) {
followLists = newFollowLists
}
}
}
var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
newNotes.forEach {
if (it.event is PeopleListEvent) {
refresh()
}
}
}
}
}
override fun onCleared() {
collectorJob?.cancel()
super.onCleared()
}
}
@Composable
fun SimpleTextSpinner(
placeholder: String,
@@ -276,8 +276,8 @@ private fun AuthorPictureAndComment(
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content)
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(newAuthor, decryptedContent.content)
} else {
if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content())
@@ -12,6 +12,7 @@ import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -24,6 +25,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
@@ -50,6 +53,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
@@ -81,6 +85,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
@@ -92,6 +97,7 @@ import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
@@ -366,6 +372,10 @@ fun NoteComposeInner(
RenderBadgeAward(note, backgroundColor, accountViewModel, navController)
}
is PeopleListEvent -> {
RenderPeopleList(noteState, backgroundColor, accountViewModel, navController)
}
is PrivateDmEvent -> {
RenderPrivateMessage(note, makeItShort, isAcceptableAndCanPreview.second, backgroundColor, accountViewModel, navController)
}
@@ -630,6 +640,114 @@ private fun RenderPrivateMessage(
)
}
@Composable
fun RenderPeopleList(
noteState: NoteState?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
DisplayPeopleList(noteState, backgroundColor, accountViewModel, navController)
noteState?.note?.let {
ReactionsRow(it, accountViewModel, navController)
}
Divider(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
)
}
@Composable
fun DisplayPeopleList(
noteState: NoteState?,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val note = remember(noteState) { noteState?.note } ?: return
val noteEvent = note.event as? PeopleListEvent ?: return
var members by remember { mutableStateOf<List<User>>(listOf()) }
val account = accountViewModel.userProfile()
var expanded by remember {
mutableStateOf(false)
}
val toMembersShow = if (expanded) {
members
} else {
members.take(3)
}
Text(
text = "#${noteEvent.dTag()}",
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(5.dp),
textAlign = TextAlign.Center
)
LaunchedEffect(key1 = noteState) {
withContext(Dispatchers.IO) {
members = noteEvent.bookmarkedPeople().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed()
}
}
Box {
FlowRow(modifier = Modifier.padding(top = 5.dp)) {
toMembersShow.forEach { user ->
Row(modifier = Modifier.fillMaxWidth()) {
UserCompose(
user,
overallModifier = Modifier,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
if (members.size > 3 && !expanded) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0f),
backgroundColor
)
)
)
) {
Button(
modifier = Modifier.padding(top = 10.dp),
onClick = { expanded = !expanded },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
.compositeOver(MaterialTheme.colors.background)
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.show_more), color = Color.White)
}
}
}
}
}
@Composable
private fun RenderBadgeAward(
note: Note,
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@@ -37,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.*
import kotlin.math.roundToInt
@@ -49,19 +49,45 @@ fun PollNote(
accountViewModel: AccountViewModel,
navController: NavController
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zappedNote = zapsState?.note ?: return
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val account = remember(accountState) { accountState?.account } ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.load(account, zappedNote)
val pollViewModel: PollNoteViewModel = viewModel()
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
LaunchedEffect(key1 = baseNote) {
pollViewModel.load(account, baseNote)
}
PollNote(
baseNote = baseNote,
pollViewModel = pollViewModel,
canPreview = canPreview,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
navController = navController
)
}
@Composable
fun PollNote(
baseNote: Note,
pollViewModel: PollNoteViewModel,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
navController: NavController
) {
val zapsState by baseNote.live().zaps.observeAsState()
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
pollViewModel.refreshTallies()
}
}
pollViewModel.tallies.forEach { poll_op ->
OptionNote(
poll_op.key,
poll_op.value,
poll_op,
pollViewModel,
baseNote,
accountViewModel,
@@ -74,8 +100,7 @@ fun PollNote(
@Composable
private fun OptionNote(
optionNumber: Int,
optionText: String,
poolOption: PollOption,
pollViewModel: PollNoteViewModel,
baseNote: Note,
accountViewModel: AccountViewModel,
@@ -88,32 +113,17 @@ private fun OptionNote(
modifier = Modifier.padding(vertical = 3.dp)
) {
if (!pollViewModel.canZap()) {
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
withContext(Dispatchers.IO) {
val myTally = pollViewModel.optionVoteTally(optionNumber)
val color = if (
pollViewModel.consensusThreshold != null &&
myTally >= pollViewModel.consensusThreshold!!
) {
Color.Green.copy(alpha = 0.32f)
} else {
defaultColor
}
if (myTally > optionTally.first || color != optionTally.second) {
optionTally = Pair(myTally, color)
}
}
val color = if (poolOption.consensusThreadhold) {
Color.Green.copy(alpha = 0.32f)
} else {
MaterialTheme.colors.primary.copy(alpha = 0.32f)
}
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {
Box(
Modifier
@@ -121,14 +131,14 @@ private fun OptionNote(
.clip(shape = RoundedCornerShape(15.dp))
.border(
2.dp,
optionTally.second,
color,
RoundedCornerShape(15.dp)
)
) {
LinearProgressIndicator(
modifier = Modifier.matchParentSize(),
color = optionTally.second,
progress = optionTally.first.toFloat()
color = color,
progress = poolOption.tally.toFloat()
)
Row(
@@ -141,7 +151,7 @@ private fun OptionNote(
.width(40.dp)
) {
Text(
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%",
fontWeight = FontWeight.Bold
)
}
@@ -152,7 +162,7 @@ private fun OptionNote(
.padding(15.dp)
) {
TranslatableRichTextViewer(
optionText,
poolOption.descriptor,
canPreview,
Modifier,
pollViewModel.pollEvent?.tags(),
@@ -170,9 +180,9 @@ private fun OptionNote(
} else {
ZapVote(
baseNote,
poolOption,
accountViewModel,
pollViewModel,
optionNumber,
nonClickablePrepend = {},
clickablePrepend = {
Box(
@@ -186,7 +196,7 @@ private fun OptionNote(
)
) {
TranslatableRichTextViewer(
optionText,
poolOption.descriptor,
canPreview,
Modifier.padding(15.dp),
pollViewModel.pollEvent?.tags(),
@@ -205,9 +215,9 @@ private fun OptionNote(
@OptIn(ExperimentalFoundationApi::class)
fun ZapVote(
baseNote: Note,
poolOption: PollOption,
accountViewModel: AccountViewModel,
pollViewModel: PollNoteViewModel,
pollOption: Int,
modifier: Modifier = Modifier,
nonClickablePrepend: @Composable () -> Unit,
clickablePrepend: @Composable () -> Unit
@@ -264,7 +274,7 @@ fun ZapVote(
)
.show()
}
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) {
// only allow one vote per option when min==max, i.e. atomic vote amount specified
scope.launch {
Toast
@@ -281,7 +291,7 @@ fun ZapVote(
accountViewModel.zap(
baseNote,
account.zapAmountChoices.first() * 1000,
pollOption,
poolOption.option,
"",
context,
onError = {
@@ -311,7 +321,7 @@ fun ZapVote(
baseNote,
accountViewModel,
pollViewModel,
pollOption,
poolOption.option,
onDismiss = {
wantsToZap = false
zappingProgress = 0f
@@ -335,17 +345,7 @@ fun ZapVote(
clickablePrepend()
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!optionWasZappedByLoggedInUser) {
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
}
}
}
if (optionWasZappedByLoggedInUser) {
if (poolOption.zappedByLoggedIn) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
@@ -372,20 +372,10 @@ fun ZapVote(
}
}
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
}
}
// only show tallies after a user has zapped note
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) {
Text(
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
showAmount(poolOption.zappedValue),
fontSize = 14.sp,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = modifier
@@ -1,5 +1,9 @@
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -8,19 +12,30 @@ import java.math.BigDecimal
import java.math.RoundingMode
import java.util.*
class PollNoteViewModel {
data class PollOption(
val option: Int,
val descriptor: String,
val zappedValue: BigDecimal,
val tally: BigDecimal,
val consensusThreadhold: Boolean,
val zappedByLoggedIn: Boolean
)
class PollNoteViewModel : ViewModel() {
var account: Account? = null
private var pollNote: Note? = null
var pollEvent: PollNoteEvent? = null
private var pollOptions: Map<Int, String>? = null
var pollOptions: Map<Int, String>? = null
var valueMaximum: Int? = null
var valueMinimum: Int? = null
private var closedAt: Int? = null
var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
var wasZappedByAuthor: Boolean = false
var wasZappedByLoggedInAccount: Boolean = false
var tallies by mutableStateOf<List<PollOption>>(emptyList())
fun load(acc: Account, note: Note?) {
account = acc
@@ -32,15 +47,35 @@ class PollNoteViewModel {
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT)
refreshTallies()
}
fun refreshTallies() {
totalZapped = totalZapped()
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
tallies = pollOptions?.keys?.map {
val zappedInOption = zappedPollOptionAmount(it)
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
} ?: emptyList()
}
fun canZap(): Boolean {
val account = account ?: return false
val user = account.userProfile() ?: return false
val note = pollNote ?: return false
return user != note.author && !wasZappedByAuthor
return user != note.author && !wasZappedByLoggedInAccount
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -88,14 +123,6 @@ class PollNoteViewModel {
return false
}
fun optionVoteTally(op: Int): BigDecimal {
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
} else {
BigDecimal.ZERO
}
}
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
return pollNote!!.zaps
.any {
@@ -105,7 +132,7 @@ class PollNoteViewModel {
}
}
fun zappedPollOptionAmount(option: Int): BigDecimal {
private fun zappedPollOptionAmount(option: Int): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
val event = it?.event as? LnZapEvent
if (event?.zappedPollOption() == option) {
@@ -116,7 +143,7 @@ class PollNoteViewModel {
} ?: BigDecimal(0)
}
fun totalZapped(): BigDecimal {
private fun totalZapped(): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
val zapEvent = (it?.event as? LnZapEvent)
@@ -25,7 +25,17 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
fun UserCompose(
baseUser: User,
overallModifier: Modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
accountViewModel: AccountViewModel,
navController: NavController
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@@ -41,12 +51,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
)
) {
Row(
modifier = Modifier
.padding(
start = 12.dp,
end = 12.dp,
top = 10.dp
),
modifier = overallModifier,
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -222,17 +223,19 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
var collectorJob: Job? = null
init {
LocalCache.live.observeForever(cacheListener)
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
}
}
fun clear() {
@@ -242,7 +245,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
override fun onCleared() {
clear()
LocalCache.live.removeObserver(cacheListener)
collectorJob?.cancel()
super.onCleared()
}
}
@@ -66,13 +66,13 @@ fun FeedView(
when (state) {
is FeedState.Empty -> {
FeedEmpty {
refreshing = true
refresh()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
refreshing = true
refresh()
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -98,7 +99,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
updateFeed(newList)
if (newList !== oldNotesState.feed.value) {
updateFeed(newList)
}
} else {
// Refresh Everything
refreshSuspended()
@@ -122,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
var collectorJob: Job? = null
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
}
}
init {
LocalCache.live.observeForever(cacheListener)
}
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
collectorJob?.cancel()
super.onCleared()
}
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
@@ -67,16 +68,18 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
bundler.invalidate()
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData()
}
var collectorJob: Job? = null
init {
LocalCache.live.observeForever(cacheListener)
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
}
}
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
collectorJob?.cancel()
super.onCleared()
}
}
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -349,7 +350,9 @@ fun NoteMaster(
)
) {
Column() {
if (noteEvent is HighlightEvent) {
if (noteEvent is PeopleListEvent) {
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),
noteEvent.author(),
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -72,16 +72,18 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
bundler.invalidate()
}
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
invalidateData()
}
var collectorJob: Job? = null
init {
LocalCache.live.observeForever(cacheListener)
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
}
}
override fun onCleared() {
LocalCache.live.removeObserver(cacheListener)
collectorJob?.cancel()
super.onCleared()
}
}
@@ -386,7 +386,6 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
account.joinChannel(channel.idHex)
navController.navigate(Route.Message.route)
},
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
@@ -20,11 +20,14 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.buttons.NewChannelButton
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.navigation.*
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
@@ -50,6 +53,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
skipHalfExpanded = true
)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account }
val followLists: FollowListViewModel = viewModel()
followLists.load(account)
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
@@ -64,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
AppBottomBar(navController, accountViewModel)
},
topBar = {
AppTopBar(navController, scaffoldState, accountViewModel)
AppTopBar(followLists, navController, scaffoldState, accountViewModel)
},
drawerContent = {
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
@@ -101,7 +110,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountV
NewNoteButton(state.account, accountViewModel, navController)
}
if (currentRoute(navController) == Route.Message.base) {
NewChannelButton(state.account)
ChannelFabColumn(state.account, navController)
}
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
NewImageButton(accountViewModel, navController)
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
@@ -18,7 +19,6 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -125,7 +126,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
@Composable
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val account = remember(accountState) { accountState?.account } ?: return
UserProfileNewThreadFeedFilter.loadUserProfile(account, user)
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
@@ -139,6 +140,10 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
NostrUserProfileDataSource.start()
}
DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
@@ -214,64 +219,14 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
}
) {
val tabs = listOf<@Composable() (() -> Unit)?>(
{
Text(text = stringResource(R.string.notes))
},
{
Text(text = stringResource(R.string.replies))
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollows = userState?.user?.transientFollowCount() ?: "--"
Text(text = "$userFollows ${stringResource(R.string.follows)}")
},
{
val userState by baseUser.live().follows.observeAsState()
val userFollowers = userState?.user?.transientFollowerCount() ?: "--"
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
},
{
val userState by baseUser.live().zaps.observeAsState()
val userZaps = userState?.user
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = userState) {
withContext(Dispatchers.IO) {
val tempAmount = userZaps?.zappedAmount()
withContext(Dispatchers.Main) {
zapAmount = tempAmount
}
}
}
Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}")
},
{
val userState by baseUser.live().bookmarks.observeAsState()
val bookmarkList = userState?.user?.latestBookmarkList
val userBookmarks =
(bookmarkList?.taggedEvents()?.count() ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0)
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
},
{
val userState by baseUser.live().reports.observeAsState()
val userReports = userState?.user?.reports?.values?.flatten()?.count()
Text(text = "$userReports ${stringResource(R.string.reports)}")
},
{
val userState by baseUser.live().relays.observeAsState()
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays = userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--"
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
}
{ Text(text = stringResource(R.string.notes)) },
{ Text(text = stringResource(R.string.replies)) },
{ FollowTabHeader(baseUser) },
{ FollowersTabHeader(baseUser) },
{ ZapTabHeader(baseUser) },
{ BookmarkTabHeader(baseUser) },
{ ReportsTabHeader(baseUser) },
{ RelaysTabHeader(baseUser) }
)
tabs.forEachIndexed { index, function ->
@@ -306,6 +261,72 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
}
}
@Composable
private fun RelaysTabHeader(baseUser: User) {
val userState by baseUser.live().relays.observeAsState()
val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" }
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
val userRelays = remember(userStateRelayInfo) { userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" }
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
}
@Composable
private fun ReportsTabHeader(baseUser: User) {
val userState by baseUser.live().reports.observeAsState()
val userReports = remember(userState) { userState?.user?.reports?.values?.flatten()?.count() }
Text(text = "$userReports ${stringResource(R.string.reports)}")
}
@Composable
private fun BookmarkTabHeader(baseUser: User) {
val userState by baseUser.live().bookmarks.observeAsState()
val userBookmarks = remember(userState) {
val bookmarkList = userState?.user?.latestBookmarkList
(bookmarkList?.taggedEvents()?.count() ?: 0) + (
bookmarkList?.taggedAddresses()?.count()
?: 0
)
}
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
}
@Composable
private fun ZapTabHeader(baseUser: User) {
val userState by baseUser.live().zaps.observeAsState()
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
LaunchedEffect(key1 = userState) {
withContext(Dispatchers.IO) {
val tempAmount = baseUser.zappedAmount()
withContext(Dispatchers.Main) {
zapAmount = tempAmount
}
}
}
Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}")
}
@Composable
private fun FollowersTabHeader(baseUser: User) {
val userState by baseUser.live().follows.observeAsState()
val userFollowers = remember(userState) { userState?.user?.transientFollowerCount() ?: "--" }
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
}
@Composable
private fun FollowTabHeader(baseUser: User) {
val userState by baseUser.live().follows.observeAsState()
val userFollows = remember(userState) { userState?.user?.transientFollowCount() ?: "--" }
Text(text = "$userFollows ${stringResource(R.string.follows)}")
}
@Composable
private fun ProfileHeader(
baseUser: User,
@@ -316,9 +337,6 @@ private fun ProfileHeader(
var popupExpanded by remember { mutableStateOf(false) }
var zoomImageDialogOpen by remember { mutableStateOf(false) }
val accountUserState by account.userProfile().live().follows.observeAsState()
val accountUser = accountUserState?.user ?: return
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
@@ -398,23 +416,7 @@ private fun ProfileHeader(
// No need for this button anymore
// NPubCopyButton(baseUser)
if (accountUser == baseUser) {
EditButton(account)
}
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (accountUser.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
if (baseUser.isFollowingCached(accountUser)) {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow_back)
} else {
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow)
}
}
ProfileActions(baseUser, account, coroutineScope)
}
}
@@ -430,14 +432,44 @@ private fun ProfileHeader(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ProfileActions(
baseUser: User,
account: Account,
coroutineScope: CoroutineScope
) {
val accountUserState by account.userProfile().live().follows.observeAsState()
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
if (accountUser == baseUser) {
EditButton(account)
}
if (account.isHidden(baseUser)) {
ShowUserButton {
account.showUser(baseUser.pubkeyHex)
}
} else if (accountUser.isFollowingCached(baseUser)) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
if (baseUser.isFollowingCached(accountUser)) {
FollowButton(
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow_back
)
} else {
FollowButton(
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow
)
}
}
}
@Composable
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val userBadgeState by baseUser.live().badges.observeAsState()
val userBadge = userBadgeState?.user ?: return
val user = remember(userState) { userState?.user } ?: return
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
@@ -513,23 +545,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
}
}
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
val badgeAwardState by baseNote.live().metadata.observeAsState()
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
}
}
}
}
}
}
DisplayBadges(baseUser, navController)
DisplayNip05ProfileStatus(user)
@@ -552,9 +568,58 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
}
}
var zapExpanded by remember { mutableStateOf(false) }
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
val pubkeyHex = remember { baseUser.pubkeyHex }
DisplayLNAddress(lud16, pubkeyHex, account, scope, context)
val lud16 = user.info?.lud16?.trim() ?: user.info?.lud06?.trim()
val identities = user.info?.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
identities.forEach { identity: IdentityClaim ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = Color.Unspecified,
painter = painterResource(id = identity.toIcon()),
contentDescription = stringResource(identity.toDescriptor()),
modifier = Modifier.size(16.dp)
)
ClickableText(
text = AnnotatedString(identity.identity),
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f)
)
}
}
}
user.info?.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = null,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@Composable
private fun DisplayLNAddress(
lud16: String?,
userHex: String,
account: Account,
scope: CoroutineScope,
context: Context
) {
var zapExpanded by remember { mutableStateOf(false) }
if (!lud16.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -576,10 +641,13 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
}
if (zapExpanded) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 5.dp)
) {
InvoiceRequest(
lud16,
baseUser.pubkeyHex,
userHex,
account,
onSuccess = {
// pay directly
@@ -619,44 +687,34 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
}
}
}
}
val identities = user.info?.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
identities.forEach { identity: IdentityClaim ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = Color.Unspecified,
painter = painterResource(id = identity.toIcon()),
contentDescription = stringResource(identity.toDescriptor()),
modifier = Modifier.size(16.dp)
)
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun DisplayBadges(
baseUser: User,
navController: NavController
) {
val userBadgeState by baseUser.live().badges.observeAsState()
val userBadge = remember(userBadgeState) { userBadgeState?.user } ?: return
ClickableText(
text = AnnotatedString(identity.identity),
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f)
)
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
val badgeAwardState by baseNote.live().metadata.observeAsState()
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
if (baseBadgeDefinition != null) {
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
}
}
}
}
}
}
user.info?.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = null,
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
navController = navController
)
}
}
}
@Composable
@@ -727,9 +785,8 @@ fun BadgeThumb(
@Composable
private fun DrawBanner(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val banner = remember(userState) { userState?.user?.info?.banner }
val banner = user.info?.banner
val clipboardManager = LocalClipboardManager.current
var zoomImageDialogOpen by remember { mutableStateOf(false) }
@@ -767,61 +824,51 @@ private fun DrawBanner(baseUser: User) {
@Composable
fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@Composable
fun TabNotesConversations(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@Composable
fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val userState by baseUser.live().bookmarks.observeAsState()
if (accountState != null) {
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
LaunchedEffect(userState) {
feedViewModel.invalidateData()
}
LaunchedEffect(Unit) {
feedViewModel.invalidateData()
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
Column(Modifier.fillMaxHeight()) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -937,41 +984,6 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
}
}
@Composable
private fun NPubCopyButton(
user: User
) {
val clipboardManager = LocalClipboardManager.current
var popupExpanded by remember { mutableStateOf(false) }
Button(
modifier = Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { popupExpanded = true },
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.Share,
contentDescription = stringResource(R.string.copies_the_public_key_to_the_clipboard_for_sharing)
)
DropdownMenu(
expanded = popupExpanded,
onDismissRequest = { popupExpanded = false }
) {
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); popupExpanded = false }) {
Text(stringResource(R.string.copy_public_key_npub_to_the_clipboard))
}
}
}
}
@Composable
private fun MessageButton(user: User, navController: NavController) {
Button(
@@ -191,19 +191,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val onlineSearch = NostrSearchEventOrUserDataSource
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
}
LaunchedEffect(db) {
withContext(Dispatchers.IO) {
if (searchBarViewModel.isSearching()) {
println("Search Active")
searchBarViewModel.invalidateData()
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData()
}
}
}
}
+37 -3
View File
@@ -333,11 +333,45 @@
<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="follow_list_selection">Követek Lista</string>
<string name="follow_list_kind3follows">Mindenki akit követek</string>
<string name="follow_list_global">Globális</string>
<string name="connect_through_your_orbot_setup_markdown">
## Kapcsolódj a TOR-on keresztül az Orbot segítségével
\n\n1. Telepítsd az [Orbot-ot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. Indítsd el az Orbot-ot
\n3. Az Orbot-ban ellenőrizd a Socks portokat. Az alapbeállítás 9050
\n4. Ha szükséges az Orbot-ban változtasd meg a portot
\n5. Ezen a felületen állítsd be a Socks portot
\n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd
</string>
<string name="orbot_socks_port">Orbot Socks portja</string>
<string name="invalid_port_number">Érvénytelen Port szám</string>
<string name="use_orbot">Használd az Orbot-ot</string>
<string name="disconnect_from_your_orbot_setup">TOR/Orbot Lekapcsolása</string>
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
<string name="app_notification_dms_channel_name">Privát üzenetek</string>
<string name="app_notification_dms_channel_description">Értesítést küld, amikor privát üzeneted érkezik</string>
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
<string name="app_notification_zaps_channel_name">Zap-et kaptál</string>
<string name="app_notification_zaps_channel_description">Értesítést küld, amikor valaki neked Zap-et küldött</string>
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">%1$s -tól</string>
<string name="app_notification_zaps_channel_message_for">%1$s -ért</string>
<string name="reply_notify">Értesítés: </string>
<string name="channel_list_join_conversation">Csatlakozz a beszélgetéshez</string>
<string name="channel_list_user_or_group_id">Felhasználó vagy Csoport azonosító</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_join_channel">Csatlakozás</string>
</resources>
+8
View File
@@ -394,4 +394,12 @@
<string name="app_notification_zaps_channel_message">%1$s sats</string>
<string name="app_notification_zaps_channel_message_from">From %1$s</string>
<string name="app_notification_zaps_channel_message_for">for %1$s</string>
<string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string>
<string name="channel_list_user_or_group_id">User or Group\'s ID</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_join_channel">Join</string>
</resources>
@@ -0,0 +1 @@
<p><i>Amethyst</i> apporte le meilleur réseau social sur votre téléphone Android. Insérez simplement votre clé privée Nostr et commencez à poster.</p><p><a href='https://github.com/nostr-protocol/nostr' target='_blank' rel='nofollow noopener'>Nostr</a> est un protocole ouvert qui permet de créer un réseau "social" mondial résistant à la censure, en transférant des notes et d'autres éléments à l'aide de relais. Il ne repose pas sur un serveur central de confiance, ce qui le rend résistant; il est basé sur des clés et des signatures cryptographiques, ce qui le rend inviolable; il ne repose pas sur des techniques P2P, ce qui le rend efficace.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

@@ -0,0 +1 @@
Nostr Client pour Android