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"
// 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)
}
}
}
@@ -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<String>(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()
}
}
}
}
@@ -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")
}
}
}
@@ -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()))
mySet.clear()
queue.drainTo(mySet)
onUpdate(mySet)
} 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
@@ -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())
@@ -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()
}
}
@@ -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<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()
}
}
@@ -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()
}
}
@@ -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)
@@ -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>
@@ -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