diff --git a/app/build.gradle b/app/build.gradle index c2aa849c9..29060a914 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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') { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index fe5cbba79..bf66a5f43 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 38f50ab20..7f7800a05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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>(setOf()) { +class LocalCacheLiveData { + private val _newEventBundles = MutableSharedFlow>() + val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view // Refreshes observers in batches. private val bundler = BundledInsert(300, Dispatchers.IO) fun invalidateData(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> - if (hasActiveObservers()) { - postValue(bundledNewNotes) - } + _newEventBundles.emit(bundledNewNotes) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 51dc0461d..310eed8d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -137,9 +137,6 @@ private fun RenderSeach( val onlineSearch = NostrSearchEventOrUserDataSource - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - val lifeCycleOwner = LocalLifecycleOwner.current // Create a channel for processing search queries. @@ -147,10 +144,12 @@ private fun RenderSeach( Channel(Channel.CONFLATED) } - LaunchedEffect(db) { - withContext(Dispatchers.IO) { - if (searchBarViewModel.isSearching()) { - searchBarViewModel.invalidateData() + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearching()) { + searchBarViewModel.invalidateData() + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 579509116..393aea7ec 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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 @@ -82,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) @@ -218,6 +220,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n }, onCancel = { postViewModel.contentToAddUrl = null + }, + onError = { + scope.launch { + postViewModel.imageUploadingError.emit(it) + } } ) } @@ -641,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) ?: "" @@ -749,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") + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index ea6dff88f..05651dcbc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -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( val dispatcher: CoroutineDispatcher = Dispatchers.Default ) { private var onlyOneInBlock = AtomicBoolean() - private var atomicSet = AtomicReference>(setOf()) - - fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) { - atomicSet.updateAndGet() { - it + newObject - } + private var queue = LinkedBlockingQueue() + fun invalidateList(newObject: T, onUpdate: suspend (Set) -> 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() + queue.drainTo(mySet) + onUpdate(mySet) + delay(delay) - onUpdate(atomicSet.getAndSet(emptySet())) + + mySet.clear() + queue.drainTo(mySet) + onUpdate(mySet) } finally { withContext(NonCancellable) { onlyOneInBlock.set(false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index bc02931e5..17d165a3d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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, + val urlSet: Set, val imagesForPager: Map, val imageList: List ) +@OptIn(ExperimentalTime::class) @Composable private fun RenderRegular( content: String, @@ -121,11 +124,13 @@ private fun RenderRegular( navController: NavController ) { var processedState by remember { - mutableStateOf(null) + mutableStateOf(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) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 5c34d567f..bdecb38e2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -271,10 +271,6 @@ private fun UrlImageView( mutableStateOf(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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 26f872111..0d715b80e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -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(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) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b18aefcda..1ebe2c432 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -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>>(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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 456bec712..86dd8e055 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 70c152010..47feb186d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -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) : ViewModel() { } } - private val cacheListener: (Set) -> 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) : ViewModel() { override fun onCleared() { clear() - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 1e512b4ac..a3f3da1a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -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 @@ -124,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - private val cacheListener: (Set) -> 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() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 8c6806de8..f89aaaeb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -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>) : Vi bundler.invalidate() } - private val cacheListener: (Set) -> 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() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index aae7fd358..3b0173753 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -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) : ViewModel() { bundler.invalidate() } - private val cacheListener: (Set) -> 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() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 54579a4d4..e230e9d42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -20,8 +20,11 @@ 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.ui.buttons.ChannelFabColumn @@ -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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 3a69e0a2d..0a4bdedd1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -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(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() + } } } } diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 64568685b..2d5f6b037 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -333,11 +333,45 @@ Saját csomópontjaid (NIP-95) A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel - - - + + + Követek Lista Mindenki akit követek Globális + + ## 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 + + Orbot Socks portja + Érvénytelen Port szám + Használd az Orbot-ot + TOR/Orbot Lekapcsolása + DefaultChannelID + New notification arrived + + PrivateMessagesID + Privát üzenetek + Értesítést küld, amikor privát üzeneted érkezik + + ZapsID + Zap-et kaptál + Értesítést küld, amikor valaki neked Zap-et küldött + %1$s sats + %1$s -tól + %1$s -ért + + Értesítés: + + Csatlakozz a beszélgetéshez + Felhasználó vagy Csoport azonosító + npub, nevent vagy hex + Létrehoz + Csatlakozás diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 100644 index 000000000..1db1b2cf4 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1 @@ +

Amethyst apporte le meilleur réseau social sur votre téléphone Android. Insérez simplement votre clé privée Nostr et commencez à poster.

Nostr 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. \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/images/icon.png b/fastlane/metadata/android/fr-FR/images/icon.png new file mode 100644 index 000000000..dfae07cf0 Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/icon.png differ diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg new file mode 100644 index 000000000..bb3443164 Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg differ diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/notifications.png b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/notifications.png new file mode 100644 index 000000000..8e302188c Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/notifications.png differ diff --git a/fastlane/metadata/android/fr-FR/images/phoneScreenshots/replies.png b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/replies.png new file mode 100644 index 000000000..70978d1df Binary files /dev/null and b/fastlane/metadata/android/fr-FR/images/phoneScreenshots/replies.png differ diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 100644 index 000000000..2984f2c4c --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +Nostr Client pour Android \ No newline at end of file