Saves Contact List locally to avoid losing follows.

This commit is contained in:
Vitor Pamplona
2023-02-21 15:48:23 -05:00
parent e8b09a9ba3
commit 5ab3ce84d3
12 changed files with 139 additions and 79 deletions
@@ -8,6 +8,9 @@ import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import java.util.Locale import java.util.Locale
import nostr.postr.Persona import nostr.postr.Persona
import nostr.postr.events.ContactListEvent
import nostr.postr.events.Event
import nostr.postr.events.Event.Companion.getRefinedEvent
import nostr.postr.toHex import nostr.postr.toHex
class LocalPreferences(context: Context) { class LocalPreferences(context: Context) {
@@ -24,6 +27,7 @@ class LocalPreferences(context: Context) {
remove("dontTranslateFrom") remove("dontTranslateFrom")
remove("translateTo") remove("translateTo")
remove("zapAmounts") remove("zapAmounts")
remove("latestContactList")
}.apply() }.apply()
} }
@@ -37,6 +41,7 @@ class LocalPreferences(context: Context) {
account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) } account.dontTranslateFrom.let { putStringSet("dontTranslateFrom", it) }
account.translateTo.let { putString("translateTo", it) } account.translateTo.let { putString("translateTo", it) }
account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) } account.zapAmountChoices.let { putString("zapAmounts", gson.toJson(it)) }
account.latestContactList.let { putString("latestContactList", Event.gson.toJson(it)) }
}.apply() }.apply()
} }
@@ -59,6 +64,15 @@ class LocalPreferences(context: Context) {
object : TypeToken<List<Long>>() {}.type object : TypeToken<List<Long>>() {}.type
) ?: listOf(500L, 1000L, 5000L) ) ?: listOf(500L, 1000L, 5000L)
val latestContactList = try {
getString("latestContactList", null)?.let {
Event.gson.fromJson(it, Event::class.java).getRefinedEvent(true) as ContactListEvent
}
} catch (e: Throwable) {
e.printStackTrace()
null
}
if (pubKey != null) { if (pubKey != null) {
return Account( return Account(
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()), Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
@@ -67,7 +81,8 @@ class LocalPreferences(context: Context) {
localRelays, localRelays,
dontTranslateFrom, dontTranslateFrom,
translateTo, translateTo,
zapAmountChoices zapAmountChoices,
latestContactList
) )
} else { } else {
return null return null
@@ -57,7 +57,8 @@ class Account(
var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(), var localRelays: Set<RelaySetupInfo> = Constants.defaultRelays.toSet(),
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(), var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var translateTo: String = Locale.getDefault().language, var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L) var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var latestContactList: ContactListEvent? = null
) { ) {
var transientHiddenUsers: Set<String> = setOf() var transientHiddenUsers: Set<String> = setOf()
@@ -80,18 +81,23 @@ class Account(
fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) { fun sendNewRelayList(relays: Map<String, ContactListEvent.ReadWrite>) {
if (!isWriteable()) return if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList val contactList = latestContactList
val event = if (lastestContactList != null) {
ContactListEvent.create( if (contactList != null && contactList.follows.size > 0) {
lastestContactList.follows, val event = ContactListEvent.create(
contactList.follows,
relays, relays,
loggedIn.privKey!!) loggedIn.privKey!!)
} else {
ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
}
Client.send(event) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
} else {
val event = ContactListEvent.create(listOf(), relays, loggedIn.privKey!!)
// Keep this local to avoid erasing a good contact list.
// Client.send(event)
LocalCache.consume(event)
}
} }
fun sendNewUserMetadata(toString: String) { fun sendNewUserMetadata(toString: String) {
@@ -208,10 +214,11 @@ class Account(
fun follow(user: User) { fun follow(user: User) {
if (!isWriteable()) return if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList val contactList = latestContactList
val event = if (lastestContactList != null) {
val event = if (contactList != null && contactList.follows.size > 0) {
ContactListEvent.create( ContactListEvent.create(
lastestContactList.follows.plus(Contact(user.pubkeyHex, null)), contactList.follows.plus(Contact(user.pubkeyHex, null)),
userProfile().relays, userProfile().relays,
loggedIn.privKey!!) loggedIn.privKey!!)
} else { } else {
@@ -230,12 +237,14 @@ class Account(
fun unfollow(user: User) { fun unfollow(user: User) {
if (!isWriteable()) return if (!isWriteable()) return
val lastestContactList = userProfile().latestContactList val contactList = latestContactList
if (lastestContactList != null) {
if (contactList != null && contactList.follows.size > 0) {
val event = ContactListEvent.create( val event = ContactListEvent.create(
lastestContactList.follows.filter { it.pubKeyHex != user.pubkeyHex }, contactList.follows.filter { it.pubKeyHex != user.pubkeyHex },
userProfile().relays, userProfile().relays,
loggedIn.privKey!!) loggedIn.privKey!!)
Client.send(event) Client.send(event)
LocalCache.consume(event) LocalCache.consume(event)
} }
@@ -309,28 +318,35 @@ class Account(
fun joinChannel(idHex: String) { fun joinChannel(idHex: String) {
followingChannels = followingChannels + idHex followingChannels = followingChannels + idHex
invalidateData(live) live.invalidateData()
saveable.invalidateData()
} }
fun leaveChannel(idHex: String) { fun leaveChannel(idHex: String) {
followingChannels = followingChannels - idHex followingChannels = followingChannels - idHex
invalidateData(live) live.invalidateData()
saveable.invalidateData()
} }
fun hideUser(pubkeyHex: String) { fun hideUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers + pubkeyHex hiddenUsers = hiddenUsers + pubkeyHex
invalidateData(live) live.invalidateData()
saveable.invalidateData()
} }
fun showUser(pubkeyHex: String) { fun showUser(pubkeyHex: String) {
hiddenUsers = hiddenUsers - pubkeyHex hiddenUsers = hiddenUsers - pubkeyHex
transientHiddenUsers = transientHiddenUsers - pubkeyHex transientHiddenUsers = transientHiddenUsers - pubkeyHex
invalidateData(live) live.invalidateData()
saveable.invalidateData()
} }
fun changeZapAmounts(newAmounts: List<Long>) { fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts zapAmountChoices = newAmounts
invalidateData(live) live.invalidateData()
saveable.invalidateData()
} }
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
@@ -384,12 +400,23 @@ class Account(
fun addDontTranslateFrom(languageCode: String) { fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode) dontTranslateFrom = dontTranslateFrom.plus(languageCode)
invalidateData(liveLanguages) liveLanguages.invalidateData()
saveable.invalidateData()
} }
fun updateTranslateTo(languageCode: String) { fun updateTranslateTo(languageCode: String) {
translateTo = languageCode translateTo = languageCode
invalidateData(liveLanguages) liveLanguages.invalidateData()
saveable.invalidateData()
}
private fun updateContactListTo(newContactList: ContactListEvent?) {
if ((newContactList?.follows?.size ?: 0) > 0 && latestContactList != newContactList) {
latestContactList = newContactList
saveable.invalidateData()
}
} }
fun activeRelays(): Array<Relay>? { fun activeRelays(): Array<Relay>? {
@@ -415,11 +442,28 @@ class Account(
} }
init { init {
latestContactList?.let {
println("Loading saved contacts ${it.toJson()}")
if (userProfile().latestContactList == null) {
LocalCache.consume(it)
}
}
// Observes relays to restart connections
userProfile().live().relays.observeForever { userProfile().live().relays.observeForever {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
reconnectIfRelaysHaveChanged() reconnectIfRelaysHaveChanged()
} }
} }
// saves contact list for the next time.
userProfile().live().follows.observeForever {
GlobalScope.launch(Dispatchers.IO) {
updateContactListTo(userProfile().latestContactList)
}
}
// imports transient blocks due to spam.
LocalCache.antiSpam.liveSpam.observeForever { LocalCache.antiSpam.liveSpam.observeForever {
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
it.cache.spamMessages.snapshot().values.forEach { it.cache.spamMessages.snapshot().values.forEach {
@@ -437,26 +481,7 @@ class Account(
// Observers line up here. // Observers line up here.
val live: AccountLiveData = AccountLiveData(this) val live: AccountLiveData = AccountLiveData(this)
val liveLanguages: AccountLiveData = AccountLiveData(this) val liveLanguages: AccountLiveData = AccountLiveData(this)
val saveable: AccountLiveData = AccountLiveData(this)
var handlerWaiting = AtomicBoolean()
@Synchronized
private fun invalidateData(live: AccountLiveData) {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
live.refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun isHidden(user: User) = user in hiddenUsers() fun isHidden(user: User) = user in hiddenUsers()
@@ -496,10 +521,32 @@ class Account(
fun saveRelayList(value: List<RelaySetupInfo>) { fun saveRelayList(value: List<RelaySetupInfo>) {
localRelays = value.toSet() localRelays = value.toSet()
sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } ) sendNewRelayList(value.associate { it.url to ContactListEvent.ReadWrite(it.read, it.write) } )
saveable.invalidateData()
} }
} }
class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) { class AccountLiveData(private val account: Account): LiveData<AccountState>(AccountState(account)) {
var handlerWaiting = AtomicBoolean()
@Synchronized
fun invalidateData() {
if (handlerWaiting.getAndSet(true)) return
handlerWaiting.set(true)
val scope = CoroutineScope(Job() + Dispatchers.Default)
scope.launch {
try {
delay(100)
refresh()
} finally {
withContext(NonCancellable) {
handlerWaiting.set(false)
}
}
}
}
fun refresh() { fun refresh() {
postValue(AccountState(account)) postValue(AccountState(account))
} }
@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.ui package com.vitorpamplona.amethyst.ui
import android.content.ComponentCallbacks2
import android.net.Uri
import android.os.Build.VERSION.SDK_INT import android.os.Build.VERSION.SDK_INT
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -16,24 +14,13 @@ import coil.ImageLoader
import coil.decode.GifDecoder import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import coil.disk.DiskCache
import coil.memory.MemoryCache
import coil.request.CachePolicy
import coil.util.DebugLogger
import com.vitorpamplona.amethyst.EncryptedStorage
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.service.Nip19 import com.vitorpamplona.amethyst.service.Nip19
import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
import nostr.postr.bechToBytes
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -64,11 +51,11 @@ class MainActivity : ComponentActivity() {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
val accountViewModel: AccountStateViewModel = viewModel { val accountStateViewModel: AccountStateViewModel = viewModel {
AccountStateViewModel(LocalPreferences(applicationContext)) AccountStateViewModel(LocalPreferences(applicationContext))
} }
AccountScreen(accountViewModel, startingPage) AccountScreen(accountStateViewModel, startingPage)
} }
} }
} }
@@ -78,6 +65,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Only starts after login // Only starts after login
ServiceManager.start() ServiceManager.start()
} }
@@ -38,8 +38,6 @@ class NewChannelViewModel: ViewModel() {
channelDescription.value.text, channelDescription.value.text,
channelPicture.value.text channelPicture.value.text
) )
LocalPreferences(context).saveToEncryptedStorage(account)
} else } else
account.sendChangeChannel( account.sendChangeChannel(
channelName.value.text, channelName.value.text,
@@ -25,7 +25,6 @@ class NewRelayListViewModel: ViewModel() {
fun create(ctx: Context) { fun create(ctx: Context) {
relays.let { relays.let {
account.saveRelayList(it.value) account.saveRelayList(it.value)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
clear(ctx) clear(ctx)
@@ -540,7 +540,6 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
SaveButton( SaveButton(
onPost = { onPost = {
postViewModel.sendPost() postViewModel.sendPost()
LocalPreferences(ctx).saveToEncryptedStorage(account)
onClose() onClose()
}, },
isActive = postViewModel.amounts.text.isNotBlank() isActive = postViewModel.amounts.text.isNotBlank()
@@ -75,7 +75,6 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
if (account.isHidden(baseUser)) { if (account.isHidden(baseUser)) {
ShowUserButton { ShowUserButton {
account.showUser(baseUser.pubkeyHex) account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
} else if (userFollows.isFollowing(baseUser)) { } else if (userFollows.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) } UnfollowButton { account.unfollow(baseUser) }
@@ -102,7 +102,6 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
if (account.isHidden(baseAuthor)) { if (account.isHidden(baseAuthor)) {
ShowUserButton { ShowUserButton {
account.showUser(baseAuthor.pubkeyHex) account.showUser(baseAuthor.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
} else if (userFollows.isFollowing(baseAuthor)) { } else if (userFollows.isFollowing(baseAuthor)) {
UnfollowButton { account.unfollow(baseAuthor) } UnfollowButton { account.unfollow(baseAuthor) }
@@ -1,16 +1,15 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import fr.acinq.secp256k1.Hex import fr.acinq.secp256k1.Hex
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -70,9 +69,35 @@ class AccountStateViewModel(private val localPreferences: LocalPreferences): Vie
scope.launch { scope.launch {
ServiceManager.start(account) ServiceManager.start(account)
} }
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)
}
}
private val saveListener: (com.vitorpamplona.amethyst.model.AccountState) -> Unit = {
GlobalScope.launch(Dispatchers.IO) {
localPreferences.saveToEncryptedStorage(it.account)
}
} }
fun logOff() { fun logOff() {
val state = accountContent.value
when (state) {
is AccountState.LoggedIn -> {
GlobalScope.launch(Dispatchers.Main) {
state.account.saveable.removeObserver(saveListener)
}
}
is AccountState.LoggedInViewOnly -> {
GlobalScope.launch(Dispatchers.Main) {
state.account.saveable.removeObserver(saveListener)
}
}
else -> {}
}
_accountContent.update { AccountState.LoggedOff } _accountContent.update { AccountState.LoggedOff }
localPreferences.clearEncryptedStorage() localPreferences.clearEncryptedStorage()
@@ -11,6 +11,8 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.LocalCacheState
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
@@ -67,21 +69,17 @@ class AccountViewModel(private val account: Account): ViewModel() {
fun hide(user: User, ctx: Context) { fun hide(user: User, ctx: Context) {
account.hideUser(user.pubkeyHex) account.hideUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
fun show(user: User, ctx: Context) { fun show(user: User, ctx: Context) {
account.showUser(user.pubkeyHex) account.showUser(user.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
fun translateTo(lang: Locale, ctx: Context) { fun translateTo(lang: Locale, ctx: Context) {
account.updateTranslateTo(lang.language) account.updateTranslateTo(lang.language)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
fun dontTranslateFrom(lang: String, ctx: Context) { fun dontTranslateFrom(lang: String, ctx: Context) {
account.addDontTranslateFrom(lang) account.addDontTranslateFrom(lang)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
} }
@@ -306,13 +306,10 @@ private fun EditButton(account: Account, channel: Channel) {
@Composable @Composable
private fun JoinButton(account: Account, channel: Channel, navController: NavController) { private fun JoinButton(account: Account, channel: Channel, navController: NavController) {
val context = LocalContext.current.applicationContext
Button( Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
onClick = { onClick = {
account.joinChannel(channel.idHex) account.joinChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route) navController.navigate(Route.Message.route)
}, },
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@@ -328,13 +325,10 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon
@Composable @Composable
private fun LeaveButton(account: Account, channel: Channel, navController: NavController) { private fun LeaveButton(account: Account, channel: Channel, navController: NavController) {
val context = LocalContext.current.applicationContext
Button( Button(
modifier = Modifier.padding(horizontal = 3.dp), modifier = Modifier.padding(horizontal = 3.dp),
onClick = { onClick = {
account.leaveChannel(channel.idHex) account.leaveChannel(channel.idHex)
LocalPreferences(context).saveToEncryptedStorage(account)
navController.navigate(Route.Message.route) navController.navigate(Route.Message.route)
}, },
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
@@ -323,7 +323,6 @@ private fun ProfileHeader(
if (account.isHidden(baseUser)) { if (account.isHidden(baseUser)) {
ShowUserButton { ShowUserButton {
account.showUser(baseUser.pubkeyHex) account.showUser(baseUser.pubkeyHex)
LocalPreferences(ctx).saveToEncryptedStorage(account)
} }
} else if (accountUser.isFollowing(baseUser)) { } else if (accountUser.isFollowing(baseUser)) {
UnfollowButton { account.unfollow(baseUser) } UnfollowButton { account.unfollow(baseUser) }