chore: clean up codebase #42
@@ -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")
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +84,7 @@ class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestMetadata(pubkey: PublicKey) {
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
metadataRequestChannel.trySend(pubkey)
|
metadataRequestChannel.trySend(pubkey)
|
||||||
|
|||||||
@@ -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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user