Merge branch 'vitorpamplona:main' into main

This commit is contained in:
Pextar
2023-05-14 08:29:09 +02:00
committed by GitHub
24 changed files with 255 additions and 132 deletions
+1 -1
View File
@@ -115,7 +115,7 @@ dependencies {
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
// Bitcoin secp256k1 bindings to Android // 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 // Nostr Base Protocol
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') { implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
@@ -877,23 +877,28 @@ class Account(
null null
} }
if (altPrivateKeyToUse != null && altPubkeyToUse != null) { try {
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey() if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
if (altPubKeyFromPrivate == event.pubKey) { if (altPubKeyFromPrivate == event.pubKey) {
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse) val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
if (result == null) { if (result == null) {
Log.w( Log.w(
"Private ZAP Decrypt", "Private ZAP Decrypt",
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}" "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
) )
}
result
} else {
null
} }
result
} else { } else {
null null
} }
} else { } catch (e: Exception) {
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
null null
} }
} }
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
import android.util.Log import android.util.Log
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
@@ -11,6 +10,8 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledInsert
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import nostr.postr.toNpub import nostr.postr.toNpub
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File 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. // Refreshes observers in batches.
private val bundler = BundledInsert<Note>(300, Dispatchers.IO) private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
fun invalidateData(newNote: Note) { fun invalidateData(newNote: Note) {
bundler.invalidateList(newNote) { bundledNewNotes -> bundler.invalidateList(newNote) { bundledNewNotes ->
if (hasActiveObservers()) { _newEventBundles.emit(bundledNewNotes)
postValue(bundledNewNotes)
}
} }
} }
} }
@@ -137,9 +137,6 @@ private fun RenderSeach(
val onlineSearch = NostrSearchEventOrUserDataSource val onlineSearch = NostrSearchEventOrUserDataSource
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries. // Create a channel for processing search queries.
@@ -147,10 +144,12 @@ private fun RenderSeach(
Channel<String>(Channel.CONFLATED) Channel<String>(Channel.CONFLATED)
} }
LaunchedEffect(db) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
if (searchBarViewModel.isSearching()) { LocalCache.live.newEventBundles.collect {
searchBarViewModel.invalidateData() if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData()
}
} }
} }
} }
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Log
import android.util.Size import android.util.Size
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
@@ -82,6 +83,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val scroolState = rememberScrollState() val scroolState = rememberScrollState()
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote) postViewModel.load(account, baseReplyTo, quote)
@@ -218,6 +220,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}, },
onCancel = { onCancel = {
postViewModel.contentToAddUrl = null postViewModel.contentToAddUrl = null
},
onError = {
scope.launch {
postViewModel.imageUploadingError.emit(it)
}
} }
) )
} }
@@ -641,7 +648,8 @@ fun ImageVideoDescription(
uri: Uri, uri: Uri,
defaultServer: ServersAvailable, defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable) -> Unit, onAdd: (String, ServersAvailable) -> Unit,
onCancel: () -> Unit onCancel: () -> Unit,
onError: (String) -> Unit
) { ) {
val resolver = LocalContext.current.contentResolver val resolver = LocalContext.current.contentResolver
val mediaType = resolver.getType(uri) ?: "" val mediaType = resolver.getType(uri) ?: ""
@@ -749,7 +757,12 @@ fun ImageVideoDescription(
LaunchedEffect(key1 = uri) { LaunchedEffect(key1 = uri) {
scope.launch(Dispatchers.IO) { 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")
}
} }
} }
@@ -8,8 +8,8 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean 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 * 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 val dispatcher: CoroutineDispatcher = Dispatchers.Default
) { ) {
private var onlyOneInBlock = AtomicBoolean() private var onlyOneInBlock = AtomicBoolean()
private var atomicSet = AtomicReference<Set<T>>(setOf<T>()) private var queue = LinkedBlockingQueue<T>()
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
atomicSet.updateAndGet() {
it + newObject
}
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) { if (onlyOneInBlock.getAndSet(true)) {
return return
} }
val scope = CoroutineScope(Job() + dispatcher) val scope = CoroutineScope(Job() + dispatcher)
scope.launch { scope.launch(Dispatchers.IO) {
try { try {
onUpdate(atomicSet.getAndSet(emptySet())) val mySet = mutableSetOf<T>()
queue.drainTo(mySet)
onUpdate(mySet)
delay(delay) delay(delay)
onUpdate(atomicSet.getAndSet(emptySet()))
mySet.clear()
queue.drainTo(mySet)
onUpdate(mySet)
} finally { } finally {
withContext(NonCancellable) { withContext(NonCancellable) {
onlyOneInBlock.set(false) 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.note.NoteCompose
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.withContext import kotlinx.coroutines.withContext
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URL import java.net.URL
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -106,11 +108,12 @@ fun RichTextViewer(
class RichTextViewerState( class RichTextViewerState(
val content: String, val content: String,
val urlSet: LinkedHashSet<String>, val urlSet: Set<String>,
val imagesForPager: Map<String, ZoomableUrlContent>, val imagesForPager: Map<String, ZoomableUrlContent>,
val imageList: List<ZoomableUrlContent> val imageList: List<ZoomableUrlContent>
) )
@OptIn(ExperimentalTime::class)
@Composable @Composable
private fun RenderRegular( private fun RenderRegular(
content: String, content: String,
@@ -121,11 +124,13 @@ private fun RenderRegular(
navController: NavController navController: NavController
) { ) {
var processedState by remember { var processedState by remember {
mutableStateOf<RichTextViewerState?>(null) mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
} }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = content) { LaunchedEffect(key1 = content) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
val imagesForPager = urlSet.mapNotNull { fullUrl -> val imagesForPager = urlSet.mapNotNull { fullUrl ->
@@ -140,7 +145,9 @@ private fun RenderRegular(
}.associateBy { it.url } }.associateBy { it.url }
val imageList = imagesForPager.values.toList() 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) mutableStateOf<Boolean?>(null)
} }
val ratio = remember {
aspectRatio(content.dim)
}
LaunchedEffect(key1 = content.url, key2 = imageState) { LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) { if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
@@ -284,10 +280,10 @@ private fun UrlImageView(
} }
BoxWithConstraints(contentAlignment = Alignment.Center) { BoxWithConstraints(contentAlignment = Alignment.Center) {
val myModifier = mainImageModifier.also { val myModifier = mainImageModifier.run {
if (ratio != null) { aspectRatio(content.dim)?.let { ratio ->
it.aspectRatio(ratio, maxHeight.isFinite) this.aspectRatio(ratio, maxHeight.isFinite)
} } ?: this
} }
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
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 kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf( val bottomNavigationItems = listOf(
Route.Home, Route.Home,
@@ -136,9 +135,10 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
} }
} }
@OptIn(ExperimentalTime::class)
@Composable @Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
val scope = rememberCoroutineScope()
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
Icon( Icon(
painter = painterResource(id = route.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 accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return val account = accountState?.account ?: return
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val notifState = NotificationCache.live.observeAsState() val notifState = NotificationCache.live.observeAsState()
val notif = notifState.value ?: return val notif = notifState.value ?: return
var hasNewItems by remember { mutableStateOf<Boolean>(false) } var hasNewItems by remember { mutableStateOf<Boolean>(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = notif) { LaunchedEffect(key1 = notif) {
scope.launch(Dispatchers.IO) { 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) { 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.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf 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
@@ -40,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import coil.Coil 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.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@Composable @Composable
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)?.substringBefore("?")) { when (currentRoute(navController)?.substringBefore("?")) {
// Route.Profile.route -> TopBarWithBackButton(navController) // Route.Profile.route -> TopBarWithBackButton(navController)
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel) Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel) Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel) else -> MainTopBar(scaffoldState, accountViewModel)
} }
} }
@Composable @Composable
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName -> FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
account.changeDefaultStoriesFollowList(listName) account.changeDefaultStoriesFollowList(listName)
} }
} }
} }
@Composable @Composable
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName -> FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName ->
account.changeDefaultHomeFollowList(listName) account.changeDefaultHomeFollowList(listName)
} }
} }
} }
@Composable @Composable
fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { account ->
FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName -> FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
account.changeDefaultNotificationFollowList(listName) account.changeDefaultNotificationFollowList(listName)
} }
} }
@@ -237,30 +240,19 @@ private fun LoggedInUserPictureDrawer(
} }
@Composable @Composable
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) { fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
// Notification
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows)) 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 globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow) val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
var followLists by remember { mutableStateOf(defaultOptions) } val followLists = remember(followListsModel.followLists) {
val followNames = remember { derivedStateOf { followLists.map { it.second } } } (defaultOptions + followListsModel.followLists)
}
LaunchedEffect(key1 = db) { val followNames = remember(followLists) {
withContext(Dispatchers.IO) { derivedStateOf {
followLists = defaultOptions + LocalCache.addressables.mapNotNull { followLists.map { it.second }
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 }
} }
} }
@@ -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 @Composable
fun SimpleTextSpinner( fun SimpleTextSpinner(
placeholder: String, placeholder: String,
@@ -276,8 +276,8 @@ private fun AuthorPictureAndComment(
(zapRequest.event as? LnZapRequestEvent)?.let { (zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = accountViewModel.decryptZap(zapRequest) val decryptedContent = accountViewModel.decryptZap(zapRequest)
if (decryptedContent != null) { if (decryptedContent != null) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
content = Pair(author, decryptedContent.content) content = Pair(newAuthor, decryptedContent.content)
} else { } else {
if (!zapRequest.event?.content().isNullOrBlank()) { if (!zapRequest.event?.content().isNullOrBlank()) {
content = Pair(author, zapRequest.event?.content()) content = Pair(author, zapRequest.event?.content())
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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 -> var collectorJob: Job? = null
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
init { 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() { fun clear() {
@@ -242,7 +245,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
override fun onCleared() { override fun onCleared() {
clear() clear()
LocalCache.live.removeObserver(cacheListener) collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
} }
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -124,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
private val cacheListener: (Set<Note>) -> Unit = { newNotes -> var collectorJob: Job? = null
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes) init {
} else { collectorJob = viewModelScope.launch(Dispatchers.IO) {
// Refresh Everything LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData() if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
// Refresh Everything
invalidateData()
}
}
} }
} }
init {
LocalCache.live.observeForever(cacheListener)
}
override fun onCleared() { override fun onCleared() {
LocalCache.live.removeObserver(cacheListener) collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
} }
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
@@ -67,16 +68,18 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
bundler.invalidate() bundler.invalidate()
} }
private val cacheListener: (Set<Note>) -> Unit = { newNotes -> var collectorJob: Job? = null
invalidateData()
}
init { init {
LocalCache.live.observeForever(cacheListener) collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
}
} }
override fun onCleared() { override fun onCleared() {
LocalCache.live.removeObserver(cacheListener) collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
} }
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FeedFilter
@@ -72,16 +72,18 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
bundler.invalidate() bundler.invalidate()
} }
private val cacheListener: (Set<Note>) -> Unit = { newNotes -> var collectorJob: Job? = null
invalidateData()
}
init { init {
LocalCache.live.observeForever(cacheListener) collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
}
} }
override fun onCleared() { override fun onCleared() {
LocalCache.live.removeObserver(cacheListener) collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
} }
@@ -20,8 +20,11 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
@@ -50,6 +53,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
skipHalfExpanded = true skipHalfExpanded = true
) )
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account }
val followLists: FollowListViewModel = viewModel()
followLists.load(account)
ModalBottomSheetLayout( ModalBottomSheetLayout(
sheetState = sheetState, sheetState = sheetState,
sheetContent = { sheetContent = {
@@ -64,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
AppBottomBar(navController, accountViewModel) AppBottomBar(navController, accountViewModel)
}, },
topBar = { topBar = {
AppTopBar(navController, scaffoldState, accountViewModel) AppTopBar(followLists, navController, scaffoldState, accountViewModel)
}, },
drawerContent = { drawerContent = {
DrawerContent(navController, scaffoldState, sheetState, accountViewModel) DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
@@ -191,19 +191,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val onlineSearch = NostrSearchEventOrUserDataSource val onlineSearch = NostrSearchEventOrUserDataSource
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
// Create a channel for processing search queries. // Create a channel for processing search queries.
val searchTextChanges = remember { val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED) CoroutineChannel<String>(CoroutineChannel.CONFLATED)
} }
LaunchedEffect(db) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
if (searchBarViewModel.isSearching()) { LocalCache.live.newEventBundles.collect {
println("Search Active") if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData() 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">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="follow_list_selection">Követek Lista</string> <string name="follow_list_selection">Követek Lista</string>
<string name="follow_list_kind3follows">Mindenki akit követek</string> <string name="follow_list_kind3follows">Mindenki akit követek</string>
<string name="follow_list_global">Globális</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> </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