chore: clean up codebase #42

Merged
reya merged 6 commits from cleanup into master 2026-07-13 01:04:16 +00:00
17 changed files with 671 additions and 900 deletions
Showing only changes of commit 8194f5eccb - Show all commits

View File

@@ -48,9 +48,9 @@ import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.account.AccountViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")

View File

@@ -14,9 +14,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import su.reya.coop.nostr.NostrManager import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.account.AccountViewModel
import kotlin.system.exitProcess import kotlin.system.exitProcess
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {

View File

@@ -97,11 +97,12 @@ import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomKind import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.ago import su.reya.coop.ago
import su.reya.coop.rememberUiState
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -111,7 +112,6 @@ fun HomeScreen() {
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val accountViewModel = LocalAccountViewModel.current val accountViewModel = LocalAccountViewModel.current
@@ -615,8 +615,10 @@ fun NewRequests(requests: List<Room>) {
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) val firstRoomState by (firstRoom as Room).uiStateFlow(nostrViewModel)
val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel) .collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(nostrViewModel)
.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when { val supportingText = when {
total == 1 -> { total == 1 -> {
@@ -688,7 +690,8 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val nostrViewModel = LocalNostrViewModel.current val nostrViewModel = LocalNostrViewModel.current
val roomState by room.rememberUiState(nostrViewModel) val roomState by room.uiStateFlow(nostrViewModel)
.collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),

View File

@@ -71,9 +71,10 @@ import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroupHeader
import su.reya.coop.rememberUiState import su.reya.coop.uiStateFlow
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -115,7 +116,8 @@ fun ChatScreen(
return return
} }
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey) val roomState by (room as Room).uiStateFlow(nostrViewModel, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) } var newOtherMessages by remember { mutableIntStateOf(0) }

View File

@@ -1,9 +1,5 @@
package su.reya.coop package su.reya.coop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -66,22 +62,6 @@ data class Room(
} }
} }
fun setKind(kind: RoomKind): Room {
return this.copy(kind = kind)
}
fun setCreatedAt(createdAt: Timestamp): Room {
return this.copy(createdAt = createdAt)
}
fun setSubject(subject: String?): Room {
return this.copy(subject = subject)
}
fun setLastMessage(message: String?): Room {
return this.copy(lastMessage = message)
}
fun isGroup(): Boolean = members.size > 1 fun isGroup(): Boolean = members.size > 1
} }
@@ -127,19 +107,6 @@ fun Room.uiStateFlow(
} }
} }
@Composable
fun Room.rememberUiState(
viewModel: NostrViewModel,
currentUser: PublicKey? = null
): State<RoomUiState> {
return remember(this, currentUser) {
uiStateFlow(
viewModel,
currentUser
)
}.collectAsStateWithLifecycle(RoomUiState())
}
fun UnsignedEvent.roomId(): Long { fun UnsignedEvent.roomId(): Long {
// Collect the author's public key and all public keys from tags // Collect the author's public key and all public keys from tags
val pubkeys: MutableList<PublicKey> = mutableListOf() val pubkeys: MutableList<PublicKey> = mutableListOf()

View File

@@ -37,18 +37,6 @@ import kotlin.time.Duration.Companion.seconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
} }
class Nostr( class Nostr(

View File

@@ -32,6 +32,7 @@ import kotlin.time.Duration
class ProfileManager(private val nostr: Nostr) { class ProfileManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
private val httpClient by lazy { HttpClient() }
private val _metadataUpdates = private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>( MutableSharedFlow<Pair<PublicKey, Metadata>>(
@@ -88,7 +89,7 @@ class ProfileManager(private val nostr: Nostr) {
try { try {
val kind = Kind.fromStd(KindStandard.CONTACT_LIST) val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong()) val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays) client?.sync(filter, relays)
} catch (e: Exception) { } catch (e: Exception) {
@@ -226,7 +227,7 @@ class ProfileManager(private val nostr: Nostr) {
// Construct request target // Construct request target
val target = mutableMapOf<RelayUrl, List<Filter>>() val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter) target[RelayUrl.parse(relay)] = listOf(filter)
} }
@@ -265,7 +266,7 @@ class ProfileManager(private val nostr: Nostr) {
suspend fun searchByAddress(query: String): PublicKey { suspend fun searchByAddress(query: String): PublicKey {
try { try {
val address = Nip05Address.parse(query) val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address) val profile = profileFromAddress(httpClient, address)
return profile.publicKey() return profile.publicKey()
} catch (e: Exception) { } catch (e: Exception) {
@@ -312,7 +313,7 @@ class ProfileManager(private val nostr: Nostr) {
try { try {
val filter = Filter().author(pubkey).limit(3u) val filter = Filter().author(pubkey).limit(3u)
val target = mutableMapOf<RelayUrl, List<Filter>>() val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter) target[RelayUrl.parse(relay)] = listOf(filter)
} }

View File

@@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) { class RelayManager(private val nostr: Nostr) {
companion object {
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
private val client: Client? get() = nostr.client private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
suspend fun connectBootstrapRelays() { suspend fun connectBootstrapRelays() {
NostrManager.BOOTSTRAP_RELAYS.forEach { url -> BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse(url)) client?.addRelay(RelayUrl.parse(url))
} }
NostrManager.INDEXER_RELAY.forEach { url -> INDEXER_RELAY.forEach { url ->
client?.addRelay( client?.addRelay(
url = RelayUrl.parse(url), url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip() capabilities = RelayCapabilities.gossip()
@@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) {
} }
suspend fun reconnect() { suspend fun reconnect() {
NostrManager.ALL_RELAYS.forEach { url -> ALL_RELAYS.forEach { url ->
try { try {
client?.relay(RelayUrl.parse(url)).let { relay -> client?.relay(RelayUrl.parse(url)).let { relay ->
if (relay != null) { if (relay != null) {
@@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) {
} }
suspend fun disconnect() { suspend fun disconnect() {
NostrManager.ALL_RELAYS.forEach { url -> ALL_RELAYS.forEach { url ->
try { try {
client?.disconnectRelay(RelayUrl.parse(url)) client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -0,0 +1,604 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountViewModel(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ViewModel(), ErrorHost by createErrorHost() {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty: StateFlow<Boolean> = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> =
_currentUserRelayList.asStateFlow()
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = _currentUserMsgRelayList.asStateFlow()
init {
checkNotificationBannerDismissedStatus()
login()
observeContactList()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
private fun onSignerReady() {
getUserMetadata()
checkRelayList()
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun getUserMetadata() {
viewModelScope.launch {
nostr.profiles.getUserMetadata()
}
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
viewModelScope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
private fun observeContactList() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
fun resetInternalState() {
_contactList.value = emptySet()
_isRelayListEmpty.value = false
}
fun addContact(address: String) {
viewModelScope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
if (pubkey in _contactList.value) return@launch
try {
val updated = _contactList.value + pubkey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in _contactList.value) return@launch
try {
val updated = _contactList.value - publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
viewModelScope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
fun checkRelayList() {
viewModelScope.launch {
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
val relays = withTimeoutOrNull(10.seconds) {
var result = nostr.relays.getMsgRelays(currentUser)
while (result.isEmpty()) {
delay(500.milliseconds)
result = nostr.relays.getMsgRelays(currentUser)
}
result
} ?: emptyList()
if (relays.isEmpty()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
viewModelScope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
viewModelScope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
viewModelScope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
fun addInboxRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
viewModelScope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
fun addMsgRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
viewModelScope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
}

View File

@@ -1,5 +1,6 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@@ -22,6 +23,8 @@ import su.reya.coop.Room
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.viewmodel.createErrorHost
import su.reya.coop.viewmodel.ErrorHost
data class ChatState( data class ChatState(
val rooms: Map<Long, Room> = emptyMap(), val rooms: Map<Long, Room> = emptyMap(),
@@ -31,7 +34,7 @@ data class ChatState(
class ChatViewModel( class ChatViewModel(
private val nostr: Nostr, private val nostr: Nostr,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
) : BaseViewModel() { ) : ViewModel(), ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn( val state = _state.stateIn(
viewModelScope, viewModelScope,

View File

@@ -1,19 +1,28 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
abstract class BaseViewModel : ViewModel() { interface ErrorHost {
val errorEvents: SharedFlow<String>
fun showError(message: String)
}
fun createErrorHost(): ErrorHost = ErrorHostImpl()
private class ErrorHostImpl : ErrorHost {
private val _errorEvents = MutableSharedFlow<String>( private val _errorEvents = MutableSharedFlow<String>(
replay = 0, replay = 0,
extraBufferCapacity = 10, extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.SUSPEND onBufferOverflow = BufferOverflow.SUSPEND
) )
val errorEvents = _errorEvents.asSharedFlow()
protected fun showError(message: String) { override val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
override fun showError(message: String) {
_errorEvents.tryEmit(message) _errorEvents.tryEmit(message)
} }
} }

View File

@@ -1,5 +1,6 @@
package su.reya.coop.viewmodel package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -13,10 +14,9 @@ import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { class NostrViewModel(private val nostr: Nostr) : ViewModel(), ErrorHost by createErrorHost() {
private val profilesMutex = Mutex() private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>() private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
@@ -51,40 +51,21 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
} }
private suspend fun runMetadataBatching() { private suspend fun runMetadataBatching() {
// Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) { while (true) {
// Get the first pubkey
val firstKey = metadataRequestChannel.receive() val firstKey = metadataRequestChannel.receive()
batch.add(firstKey) val batch = mutableSetOf(firstKey)
// Get current time // Collect up to 10 keys that arrive within 500ms
val lastFlushTime = Clock.System.now().toEpochMilliseconds() while (batch.size < 10) {
val nextKey =
while (batch.isNotEmpty()) { withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() }
// Get the next pubkey ?: break
val nextKey = withTimeoutOrNull(timeout.milliseconds) { batch.add(nextKey)
metadataRequestChannel.receive()
}
// Only add the pubkey if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.profiles.fetchMetadataBatch(keysToRequest)
}
} }
nostr.profiles.fetchMetadataBatch(batch.toList())
} }
} }

View File

@@ -1,252 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.AppStorage
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val importError: String? = null,
)
class AccountAuthDelegate(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
private val onError: (String) -> Unit,
private val onSignerReady: () -> Unit,
private val scope: CoroutineScope,
) {
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
fun init() {
checkNotificationBannerDismissedStatus()
login()
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
onSignerReady()
}.onFailure { e ->
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
onError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
onError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler ?: throw IllegalStateException("Not available")
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true, importError = null) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
onSignerReady()
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
onError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) }
}
}
}
}

View File

@@ -1,137 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.nostr.Nostr
class AccountContactDelegate(
private val nostr: Nostr,
private val scope: CoroutineScope,
private val onError: (String) -> Unit,
) {
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList: StateFlow<Set<PublicKey>> = _contactList.asStateFlow()
fun init() {
observeContactList()
}
fun reset() {
_contactList.value = emptySet()
}
private fun observeContactList() {
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
}
private fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
scope.launch {
try {
val updated = contactList.value + publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it + publicKey }
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun addContact(address: String) {
scope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
onError("Invalid contact address: ${e.message}")
return@launch
}
newContact(pubkey)
}
}
fun removeContact(publicKey: PublicKey) {
scope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
nostr.profiles.setContactList(updated.toList())
_contactList.update { it - publicKey }
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
onError("Error: ${e.message}")
onResult(emptySet())
}
}
}
}

View File

@@ -1,81 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
class AccountProfileDelegate(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val onError: (String) -> Unit,
private val scope: CoroutineScope,
) {
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun getUserMetadata() {
scope.launch {
nostr.profiles.getUserMetadata()
}
}
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
scope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
onError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
}

View File

@@ -1,185 +0,0 @@
package su.reya.coop.viewmodel.account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.seconds
class AccountRelayDelegate(
private val nostr: Nostr,
private val onError: (String) -> Unit,
private val scope: CoroutineScope,
) {
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _currentUserRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val currentUserRelayList = _currentUserRelayList.asStateFlow()
private val _currentUserMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val currentUserMsgRelayList = _currentUserMsgRelayList.asStateFlow()
fun reset() {
_isRelayListEmpty.value = false
}
@OptIn(ExperimentalCoroutinesApi::class)
fun checkRelayList() {
scope.launch {
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
println("user: ${currentUser.toBech32()}")
// Small delay to ensure subscription is ready
delay(6.seconds)
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _isRelayListEmpty.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserRelayList.value = nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
return emptyMap()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val currentUser =
nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_currentUserMsgRelayList.value = nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
private suspend fun currentUserMsgRelayListInternal(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
onError("Error: ${e.message}")
return emptyList()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayListInternal().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
onError("Error: ${e.message}")
}
}
}
}

View File

@@ -1,146 +0,0 @@
package su.reya.coop.viewmodel.account
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.BaseViewModel
class AccountViewModel(
nostr: Nostr,
storage: AppStorage,
mediaRepository: MediaRepository,
externalSignerHandler: ExternalSignerHandler? = null,
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : BaseViewModel() {
private val relays = AccountRelayDelegate(
nostr = nostr,
onError = ::showError,
scope = viewModelScope,
)
private val auth = AccountAuthDelegate(
nostr = nostr,
storage = storage,
mediaRepository = mediaRepository,
externalSignerHandler = externalSignerHandler,
defaultDispatcher = defaultDispatcher,
onError = ::showError,
onSignerReady = {
profile.getUserMetadata()
relays.checkRelayList()
},
scope = viewModelScope,
)
private val profile = AccountProfileDelegate(
nostr = nostr,
mediaRepository = mediaRepository,
onError = ::showError,
scope = viewModelScope,
)
private val contacts = AccountContactDelegate(
nostr = nostr,
onError = ::showError,
scope = viewModelScope,
)
val state = auth.state
val currentUserProfile: StateFlow<Profile?> = profile.currentUserProfile
val isUpdatingProfile: StateFlow<Boolean> = profile.isUpdatingProfile
val contactList: StateFlow<Set<PublicKey>> = contacts.contactList
val isRelayListEmpty: StateFlow<Boolean> = relays.isRelayListEmpty
val currentUserRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = relays.currentUserRelayList
val currentUserMsgRelayList: StateFlow<List<RelayUrl>> = relays.currentUserMsgRelayList
init {
auth.init()
contacts.init()
}
fun logout(onLogout: () -> Unit = {}) = auth.logout(onLogout)
fun dismissNotificationBanner() = auth.dismissNotificationBanner()
fun connectExternalSigner() = auth.connectExternalSigner()
fun isExternalSignerAvailable(): Boolean = auth.isExternalSignerAvailable()
fun importIdentity(secret: String, password: String? = null) =
auth.importIdentity(secret, password)
fun createIdentity(
name: String,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null,
) = auth.createIdentity(name, bio, picture, contentType)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null,
) {
profile.updateProfile(name, bio, picture, contentType)
}
fun resetInternalState() {
contacts.reset()
relays.reset()
}
fun addContact(address: String) = contacts.addContact(address)
fun removeContact(publicKey: PublicKey) = contacts.removeContact(publicKey)
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
contacts.searchByAddress(query, onResult)
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
contacts.searchByNostr(query, onResult)
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
contacts.verifyActivity(pubkey, onResult)
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
contacts.verifyContact(pubkey, onResult)
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
contacts.mutualContacts(pubkey, onResult)
fun dismissRelayWarning() = relays.dismissRelayWarning()
fun refetchMsgRelays() = relays.refetchMsgRelays()
fun useDefaultMsgRelayList() = relays.useDefaultMsgRelayList()
fun loadCurrentUserRelayList() = relays.loadCurrentUserRelayList()
fun addInboxRelay(relay: String) = relays.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = relays.addOutboxRelay(relay)
fun loadCurrentUserMsgRelayList() = relays.loadCurrentUserMsgRelayList()
fun addMsgRelay(relay: String) = relays.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = relays.removeMsgRelay(relay)
}