diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index b4787cf..1646718 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -48,9 +48,9 @@ import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.chat.ChatScreen +import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel -import su.reya.coop.viewmodel.account.AccountViewModel val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index 3c73159..273b77e 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -14,9 +14,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import su.reya.coop.nostr.NostrManager import su.reya.coop.repository.MediaRepository +import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.NostrViewModel -import su.reya.coop.viewmodel.account.AccountViewModel import kotlin.system.exitProcess class MainActivity : ComponentActivity() { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index bc35c80..3e0aaa2 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -97,11 +97,12 @@ import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.RoomKind +import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.ago -import su.reya.coop.rememberUiState import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.uiStateFlow @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable @@ -111,7 +112,6 @@ fun HomeScreen() { val qrScanResult = LocalScanResult.current val snackbarHostState = LocalSnackbarHostState.current val clipboardManager = LocalClipboard.current - val nostrViewModel = LocalNostrViewModel.current val chatViewModel = LocalChatViewModel.current val accountViewModel = LocalAccountViewModel.current @@ -615,8 +615,10 @@ fun NewRequests(requests: List) { val firstRoom = requests.getOrNull(0) val secondRoom = requests.getOrNull(1) - val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel) - val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel) + val firstRoomState by (firstRoom as Room).uiStateFlow(nostrViewModel) + .collectAsStateWithLifecycle(RoomUiState()) + val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(nostrViewModel) + .collectAsStateWithLifecycle(RoomUiState()) val supportingText = when { total == 1 -> { @@ -688,7 +690,8 @@ fun NewRequests(requests: List) { @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { val nostrViewModel = LocalNostrViewModel.current - val roomState by room.rememberUiState(nostrViewModel) + val roomState by room.uiStateFlow(nostrViewModel) + .collectAsStateWithLifecycle(RoomUiState()) ListItem( modifier = Modifier.clickable(onClick = onClick), diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index a8f951a..0f06483 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -71,9 +71,10 @@ import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.RoomUiState import su.reya.coop.Screen import su.reya.coop.formatAsGroupHeader -import su.reya.coop.rememberUiState +import su.reya.coop.uiStateFlow import su.reya.coop.roomId import su.reya.coop.shared.Avatar @@ -115,7 +116,8 @@ fun ChatScreen( 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 loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index d06109b..ce71bc4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -1,9 +1,5 @@ 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.combine 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 } @@ -127,19 +107,6 @@ fun Room.uiStateFlow( } } -@Composable -fun Room.rememberUiState( - viewModel: NostrViewModel, - currentUser: PublicKey? = null -): State { - return remember(this, currentUser) { - uiStateFlow( - viewModel, - currentUser - ) - }.collectAsStateWithLifecycle(RoomUiState()) -} - fun UnsignedEvent.roomId(): Long { // Collect the author's public key and all public keys from tags val pubkeys: MutableList = mutableListOf() diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt index 27d4031..e7617d4 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt @@ -37,18 +37,6 @@ import kotlin.time.Duration.Companion.seconds object NostrManager { 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( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt index 7579420..f228757 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/ProfileManager.kt @@ -32,6 +32,7 @@ import kotlin.time.Duration class ProfileManager(private val nostr: Nostr) { private val client: Client? get() = nostr.client private val signer: UniversalSigner get() = nostr.signer + private val httpClient by lazy { HttpClient() } private val _metadataUpdates = MutableSharedFlow>( @@ -88,7 +89,7 @@ class ProfileManager(private val nostr: Nostr) { try { val kind = Kind.fromStd(KindStandard.CONTACT_LIST) 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) } catch (e: Exception) { @@ -226,7 +227,7 @@ class ProfileManager(private val nostr: Nostr) { // Construct request target val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + RelayManager.BOOTSTRAP_RELAYS.forEach { relay -> target[RelayUrl.parse(relay)] = listOf(filter) } @@ -265,7 +266,7 @@ class ProfileManager(private val nostr: Nostr) { suspend fun searchByAddress(query: String): PublicKey { try { val address = Nip05Address.parse(query) - val profile = profileFromAddress(HttpClient(), address) + val profile = profileFromAddress(httpClient, address) return profile.publicKey() } catch (e: Exception) { @@ -312,7 +313,7 @@ class ProfileManager(private val nostr: Nostr) { try { val filter = Filter().author(pubkey).limit(3u) val target = mutableMapOf>() - NostrManager.BOOTSTRAP_RELAYS.forEach { relay -> + RelayManager.BOOTSTRAP_RELAYS.forEach { relay -> target[RelayUrl.parse(relay)] = listOf(filter) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt index 6c7d57f..12482bd 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt @@ -20,14 +20,28 @@ import rust.nostr.sdk.nip17ExtractRelayList import kotlin.time.Duration 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 signer: UniversalSigner get() = nostr.signer suspend fun connectBootstrapRelays() { - NostrManager.BOOTSTRAP_RELAYS.forEach { url -> + BOOTSTRAP_RELAYS.forEach { url -> client?.addRelay(RelayUrl.parse(url)) } - NostrManager.INDEXER_RELAY.forEach { url -> + INDEXER_RELAY.forEach { url -> client?.addRelay( url = RelayUrl.parse(url), capabilities = RelayCapabilities.gossip() @@ -38,7 +52,7 @@ class RelayManager(private val nostr: Nostr) { } suspend fun reconnect() { - NostrManager.ALL_RELAYS.forEach { url -> + ALL_RELAYS.forEach { url -> try { client?.relay(RelayUrl.parse(url)).let { relay -> if (relay != null) { @@ -54,7 +68,7 @@ class RelayManager(private val nostr: Nostr) { } suspend fun disconnect() { - NostrManager.ALL_RELAYS.forEach { url -> + ALL_RELAYS.forEach { url -> try { client?.disconnectRelay(RelayUrl.parse(url)) } catch (e: Exception) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt new file mode 100644 index 0000000..f703017 --- /dev/null +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/AccountViewModel.kt @@ -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 = _state.asStateFlow() + + private val _isUpdatingProfile = MutableStateFlow(false) + val isUpdatingProfile: StateFlow = _isUpdatingProfile.asStateFlow() + + @OptIn(ExperimentalCoroutinesApi::class) + val currentUserProfile: StateFlow = nostr.signer.publicKeyFlow + .flatMapLatest { pubkey -> + if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + private val _contactList = MutableStateFlow>(emptySet()) + val contactList: StateFlow> = _contactList.asStateFlow() + + private val _isRelayListEmpty = MutableStateFlow(false) + val isRelayListEmpty: StateFlow = _isRelayListEmpty.asStateFlow() + + private val _currentUserRelayList = MutableStateFlow>(emptyMap()) + val currentUserRelayList: StateFlow> = + _currentUserRelayList.asStateFlow() + + private val _currentUserMsgRelayList = MutableStateFlow>(emptyList()) + val currentUserMsgRelayList: StateFlow> = _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 = 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) -> 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) -> 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 { + 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 { + 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}") + } + } + } +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt index 8ea4735..a916a52 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatViewModel.kt @@ -1,5 +1,6 @@ package su.reya.coop.viewmodel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.BufferOverflow @@ -22,6 +23,8 @@ import su.reya.coop.Room import su.reya.coop.nostr.Nostr import su.reya.coop.repository.MediaRepository import su.reya.coop.roomId +import su.reya.coop.viewmodel.createErrorHost +import su.reya.coop.viewmodel.ErrorHost data class ChatState( val rooms: Map = emptyMap(), @@ -31,7 +34,7 @@ data class ChatState( class ChatViewModel( private val nostr: Nostr, private val mediaRepository: MediaRepository, -) : BaseViewModel() { +) : ViewModel(), ErrorHost by createErrorHost() { private val _state = MutableStateFlow(ChatState()) val state = _state.stateIn( viewModelScope, diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ErrorHost.kt similarity index 51% rename from shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt rename to shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ErrorHost.kt index e4d17d4..a8fc6bf 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/BaseViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ErrorHost.kt @@ -1,19 +1,28 @@ package su.reya.coop.viewmodel -import androidx.lifecycle.ViewModel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -abstract class BaseViewModel : ViewModel() { +interface ErrorHost { + val errorEvents: SharedFlow + + fun showError(message: String) +} + +fun createErrorHost(): ErrorHost = ErrorHostImpl() + +private class ErrorHostImpl : ErrorHost { private val _errorEvents = MutableSharedFlow( replay = 0, extraBufferCapacity = 10, onBufferOverflow = BufferOverflow.SUSPEND ) - val errorEvents = _errorEvents.asSharedFlow() - protected fun showError(message: String) { + override val errorEvents: SharedFlow = _errorEvents.asSharedFlow() + + override fun showError(message: String) { _errorEvents.tryEmit(message) } -} \ No newline at end of file +} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt index 8960ac9..93d21ab 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/NostrViewModel.kt @@ -1,5 +1,6 @@ package su.reya.coop.viewmodel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope @@ -13,10 +14,9 @@ import kotlinx.coroutines.withTimeoutOrNull import rust.nostr.sdk.PublicKey import su.reya.coop.Profile import su.reya.coop.nostr.Nostr -import kotlin.time.Clock 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 profiles = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) @@ -51,40 +51,21 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } private suspend fun runMetadataBatching() { - // Wait until the client is ready nostr.waitUntilInitialized() - val batch = mutableSetOf() - val timeout = 500L // 500ms timeout for batching - while (true) { - // Get the first pubkey val firstKey = metadataRequestChannel.receive() - batch.add(firstKey) + val batch = mutableSetOf(firstKey) - // Get current time - val lastFlushTime = Clock.System.now().toEpochMilliseconds() - - while (batch.isNotEmpty()) { - // Get the next pubkey - val nextKey = withTimeoutOrNull(timeout.milliseconds) { - 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) - } + // Collect up to 10 keys that arrive within 500ms + while (batch.size < 10) { + val nextKey = + withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() } + ?: break + batch.add(nextKey) } + + nostr.profiles.fetchMetadataBatch(batch.toList()) } } @@ -103,7 +84,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() { } } } - + private fun requestMetadata(pubkey: PublicKey) { if (seenPublicKeys.add(pubkey)) { metadataRequestChannel.trySend(pubkey) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt deleted file mode 100644 index f6dc1d5..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountAuthDelegate.kt +++ /dev/null @@ -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 = _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 = 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) } - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt deleted file mode 100644 index 26f16c4..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountContactDelegate.kt +++ /dev/null @@ -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>(emptySet()) - val contactList: StateFlow> = _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) -> 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) -> Unit) { - scope.launch { - try { - onResult(nostr.profiles.mutualContacts(pubkey)) - } catch (e: Exception) { - onError("Error: ${e.message}") - onResult(emptySet()) - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt deleted file mode 100644 index 0929760..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountProfileDelegate.kt +++ /dev/null @@ -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 = _isUpdatingProfile.asStateFlow() - - @OptIn(ExperimentalCoroutinesApi::class) - val currentUserProfile: StateFlow = 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 - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt deleted file mode 100644 index 99621aa..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountRelayDelegate.kt +++ /dev/null @@ -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>(emptyMap()) - val currentUserRelayList = _currentUserRelayList.asStateFlow() - - private val _currentUserMsgRelayList = MutableStateFlow>(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 { - 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 { - 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}") - } - } - } -} diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt deleted file mode 100644 index fb5ed41..0000000 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/account/AccountViewModel.kt +++ /dev/null @@ -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.currentUserProfile - - val isUpdatingProfile: StateFlow = profile.isUpdatingProfile - - val contactList: StateFlow> = contacts.contactList - - val isRelayListEmpty: StateFlow = relays.isRelayListEmpty - - val currentUserRelayList: StateFlow> = relays.currentUserRelayList - - val currentUserMsgRelayList: StateFlow> = 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) -> 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) -> 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) -}