4 Commits

Author SHA1 Message Date
6bb4ef2805 add settings viewmodel 2026-07-20 08:52:38 +07:00
e4e73da229 update settings 2026-07-20 08:32:34 +07:00
255c840847 clean up 2026-07-18 16:11:34 +07:00
9defad522c feat: add some basic interactive animations (#46)
Reviewed-on: #46
2026-07-17 09:27:39 +00:00
18 changed files with 245 additions and 123 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ captures
!*.xcworkspace/contents.xcworkspacedata !*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings **/xcshareddata/WorkspaceSettings.xcsettings
node_modules/ node_modules/
.artifacts/

View File

@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -40,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -57,11 +57,16 @@ import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.ProfileCache import su.reya.coop.viewmodel.ProfileCache
import su.reya.coop.viewmodel.SettingsViewModel
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> { val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided") error("No ProfileCache provided")
} }
val LocalSettings = staticCompositionLocalOf<Settings> {
error("No Settings provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
@@ -80,6 +85,7 @@ fun App(
profileCache: ProfileCache, profileCache: ProfileCache,
accountRepository: AccountRepository, accountRepository: AccountRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
settingsRepository: SettingsRepository,
) { ) {
val viewModelFactory = remember { val viewModelFactory = remember {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@@ -93,6 +99,10 @@ fun App(
accountRepository accountRepository
) )
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -103,6 +113,7 @@ fun App(
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory) val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory) val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
@@ -114,19 +125,24 @@ fun App(
val accountState by accountViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired val signerRequired = accountState.signerRequired
// Get the settings
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled // Check if dark theme enabled
val darkMode = isSystemInDarkTheme() val darkMode = when (settings.theme) {
Theme.Light -> false
Theme.Dark -> true
Theme.System -> isSystemInDarkTheme()
}
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
context
)
} }
// When dark mode is enabled, use the dark color scheme // When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
@@ -134,10 +150,6 @@ fun App(
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
launch { launch {
accountViewModel.errorEvents.collect { message -> accountViewModel.errorEvents.collect { message ->
@@ -195,11 +207,11 @@ fun App(
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalProfileCache provides profileCache, LocalProfileCache provides profileCache,
LocalSettings provides settings,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { onBack = {

View File

@@ -14,6 +14,7 @@ import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache import su.reya.coop.viewmodel.ProfileCache
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -25,16 +26,28 @@ class MainActivity : ComponentActivity() {
private val profileCache by lazy { ProfileCache(NostrManager.instance) } private val profileCache by lazy { ProfileCache(NostrManager.instance) }
private val scope = MainScope() private val scope = MainScope()
private val settingsRepository by lazy {
val storage = AppStore(this@MainActivity)
SettingsRepository(storage, scope)
}
private val accountRepository by lazy { private val accountRepository by lazy {
val storage = AppStore(this@MainActivity) val storage = AppStore(this@MainActivity)
val mediaRepository = MediaRepository() val mediaRepository = MediaRepository(settingsRepository)
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher) val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner) AccountRepository(
NostrManager.instance,
storage,
mediaRepository,
settingsRepository,
scope,
androidSigner
)
} }
private val chatRepository by lazy { private val chatRepository by lazy {
val mediaRepository = MediaRepository() val mediaRepository = MediaRepository(settingsRepository)
ChatRepository(NostrManager.instance, mediaRepository, scope) ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -82,6 +95,7 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache, profileCache = profileCache,
accountRepository = accountRepository, accountRepository = accountRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
settingsRepository = settingsRepository,
) )
} }
} }

View File

@@ -73,7 +73,8 @@ fun ContactListScreen(
) { ) {
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
var openAddContactDialog by remember { mutableStateOf(false) } var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) } var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }

View File

@@ -7,6 +7,7 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -86,6 +87,7 @@ import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_request
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -124,13 +126,13 @@ fun HomeScreen(
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isRelayListEmpty = accountState.isRelayListEmpty
val isBannerDismissed = accountState.isNotificationBannerDismissed
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = accountState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
@@ -249,7 +251,9 @@ fun HomeScreen(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()), modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
if (!isNotificationEnabled && !isBannerDismissed) { AnimatedVisibility(
visible = !isNotificationEnabled && !isBannerDismissed,
) {
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -372,10 +376,14 @@ fun HomeScreen(
} }
items(ongoing, key = { it.id }) { room -> items(ongoing, key = { it.id }) { room ->
ChatRoom( Row(
room = room, modifier = Modifier.animateItem()
onClick = { navigator.navigate(Screen.Chat(room.id)) } ) {
) ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
} }
} }
} }
@@ -620,13 +628,16 @@ 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).uiStateFlow(profileCache) val firstRoomState by remember(firstRoom?.id) {
.collectAsStateWithLifecycle(RoomUiState()) firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) }.collectAsStateWithLifecycle(RoomUiState())
.collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by remember(secondRoom?.id) {
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
}.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when { val supportingText = when {
total == 1 -> { total == 1 && firstRoom != null -> {
val message = firstRoom.lastMessage ?: "" val message = firstRoom.lastMessage ?: ""
"${firstRoomState.name}: $message" "${firstRoomState.name}: $message"
} }
@@ -695,7 +706,8 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState()) val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
.collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),

View File

@@ -101,13 +101,6 @@ fun ImportScreen(viewModel: AccountViewModel) {
} }
} }
// Show import errors via snackbar
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },

View File

@@ -75,7 +75,8 @@ fun NewChatScreen(
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val createGroup = remember { mutableStateOf(false) } val createGroup = remember { mutableStateOf(false) }
@@ -185,8 +186,8 @@ fun NewChatScreen(
) )
}, },
text = { Text("Next") }, text = { Text("Next") },
containerColor = MaterialTheme.colorScheme.tertiary, containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiary, contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
) )
} }
} }

View File

@@ -70,13 +70,6 @@ fun OnboardingScreen(viewModel: AccountViewModel) {
} }
} }
// Show connection errors
LaunchedEffect(accountState.importError) {
accountState.importError?.let {
snackbarHostState.showSnackbar(it)
}
}
val logoPainter = painterResource(Res.drawable.coop) val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily() val expressiveFont = getExpressiveFontFamily()

View File

@@ -100,8 +100,9 @@ fun RelayScreen(viewModel: AccountViewModel) {
viewModel.loadCurrentUserMsgRelayList() viewModel.loadCurrentUserMsgRelayList()
} }
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle() val accountState by viewModel.state.collectAsStateWithLifecycle()
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle() val loadedRelayList = accountState.userRelayList
val loadedMsgRelayList = accountState.userMsgRelayList
LaunchedEffect(loadedRelayList) { LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) { if (loadedRelayList.isNotEmpty()) {

View File

@@ -1,10 +1,5 @@
package su.reya.coop.screens.chat package su.reya.coop.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -177,11 +172,7 @@ fun ChatMessage(
) )
} }
} }
AnimatedVisibility( if (isMessageClicked) {
visible = isMessageClicked,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text( Text(
text = model.timestamp, text = model.timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,

View File

@@ -146,8 +146,9 @@ fun ChatScreen(
val groupedMessages = val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) val roomState by remember(id, currentUser?.publicKey) {
.collectAsStateWithLifecycle(RoomUiState()) (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
}.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) } var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
@@ -295,7 +296,9 @@ fun ChatScreen(
} }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { ReplyPreview(it, model.isMine) } replyPreview?.let { ReplyPreview(it, model.isMine) }
@@ -380,8 +383,10 @@ fun ChatScreen(
} }
else -> { else -> {
replyingTo?.let { AnimatedVisibility(visible = replyingTo != null) {
ReplyBox(it) { replyingTo = null } replyingTo?.let {
ReplyBox(it) { replyingTo = null }
}
} }
ChatInput( ChatInput(
value = text, value = text,
@@ -420,7 +425,6 @@ fun ChatScreen(
} }
} }
) )
AnimatedVisibility( AnimatedVisibility(
visible = selectedMessage != null, visible = selectedMessage != null,
enter = fadeIn(), enter = fadeIn(),

View File

@@ -0,0 +1,22 @@
package su.reya.coop
import kotlinx.serialization.Serializable
@Serializable
data class Settings(
val theme: Theme = Theme.System,
val dynamicColor: Boolean = true,
val media: Media = Media.AlwaysEnabled,
val screening: Boolean = true,
val blossomServer: String? = "https://blossom.band",
)
@Serializable
enum class Theme {
Light, Dark, System
}
@Serializable
enum class Media {
Disabled, DisabledForMobileData, AlwaysEnabled
}

View File

@@ -47,13 +47,17 @@ data class AccountState(
val signerRequired: Boolean? = null, val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false, val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false, val isImporting: Boolean = false,
val importError: String? = null, val isRelayListEmpty: Boolean = false,
val contactList: Set<PublicKey> = emptySet(),
val userRelayList: Map<RelayUrl, RelayMetadata?> = emptyMap(),
val userMsgRelayList: List<RelayUrl> = emptyList(),
) )
class AccountRepository( class AccountRepository(
private val nostr: Nostr, private val nostr: Nostr,
private val storage: AppStorage, private val storage: AppStorage,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null, private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
@@ -72,23 +76,9 @@ class AccountRepository(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey -> .flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null) .stateIn(scope, 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 _userRelayList = MutableStateFlow<Map<RelayUrl, RelayMetadata?>>(emptyMap())
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = _userRelayList.asStateFlow()
private val _userMsgRelayList = MutableStateFlow<List<RelayUrl>>(emptyList())
val userMsgRelayList: StateFlow<List<RelayUrl>> = _userMsgRelayList.asStateFlow()
init { init {
checkNotificationBannerDismissedStatus() checkNotificationBannerDismissedStatus()
login() login()
@@ -116,7 +106,7 @@ class AccountRepository(
} ?: emptyList() } ?: emptyList()
// Automatically update the warning state // Automatically update the warning state
_isRelayListEmpty.value = relays.isEmpty() _state.update { it.copy(isRelayListEmpty = relays.isEmpty()) }
} }
} }
} }
@@ -229,7 +219,7 @@ class AccountRepository(
fun importIdentity(secret: String, password: String? = null) { fun importIdentity(secret: String, password: String? = null) {
scope.launch { scope.launch {
_state.update { it.copy(isImporting = true, importError = null) } _state.update { it.copy(isImporting = true) }
try { try {
val (signer, decryptedSecret) = createSigner(secret, password) val (signer, decryptedSecret) = createSigner(secret, password)
@@ -239,14 +229,14 @@ class AccountRepository(
_state.update { it.copy(signerRequired = false, isImporting = false) } _state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Import failed: ${e.message}") showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) } _state.update { it.copy(isImporting = false) }
} }
} }
} }
fun connectExternalSigner() { fun connectExternalSigner() {
scope.launch { scope.launch {
_state.update { it.copy(isImporting = true, importError = null) } _state.update { it.copy(isImporting = true) }
try { try {
val handler = val handler =
externalSignerHandler ?: throw IllegalStateException("Signer not available") externalSignerHandler ?: throw IllegalStateException("Signer not available")
@@ -276,7 +266,7 @@ class AccountRepository(
_state.update { it.copy(signerRequired = false, isImporting = false) } _state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) { } catch (e: Exception) {
showError("External signer connection failed: ${e.message}") showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) } _state.update { it.copy(isImporting = false) }
} }
} }
} }
@@ -288,7 +278,7 @@ class AccountRepository(
contentType: String? = null contentType: String? = null
) { ) {
scope.launch { scope.launch {
_state.update { it.copy(isImporting = true, importError = null) } _state.update { it.copy(isImporting = true) }
try { try {
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
@@ -307,7 +297,7 @@ class AccountRepository(
_state.update { it.copy(signerRequired = false, isImporting = false) } _state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Identity creation failed: ${e.message}") showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false, importError = e.message) } _state.update { it.copy(isImporting = false) }
} }
} }
} }
@@ -355,14 +345,13 @@ class AccountRepository(
scope.launch { scope.launch {
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts -> nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet() _state.update { it.copy(contactList = contacts.toSet()) }
} }
} }
} }
fun resetInternalState() { fun resetInternalState() {
_contactList.value = emptySet() _state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
_isRelayListEmpty.value = false
} }
fun addContact(address: String) { fun addContact(address: String) {
@@ -378,12 +367,12 @@ class AccountRepository(
return@launch return@launch
} }
if (pubkey in _contactList.value) return@launch if (pubkey in _state.value.contactList) return@launch
try { try {
val updated = _contactList.value + pubkey val updated = _state.value.contactList + pubkey
nostr.profiles.setContactList(updated.toList()) nostr.profiles.setContactList(updated.toList())
_contactList.update { it + pubkey } _state.update { it.copy(contactList = it.contactList + pubkey) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -392,12 +381,12 @@ class AccountRepository(
fun removeContact(publicKey: PublicKey) { fun removeContact(publicKey: PublicKey) {
scope.launch { scope.launch {
if (publicKey !in _contactList.value) return@launch if (publicKey !in _state.value.contactList) return@launch
try { try {
val updated = _contactList.value - publicKey val updated = _state.value.contactList - publicKey
nostr.profiles.setContactList(updated.toList()) nostr.profiles.setContactList(updated.toList())
_contactList.update { it - publicKey } _state.update { it.copy(contactList = it.contactList - publicKey) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -460,7 +449,7 @@ class AccountRepository(
} }
fun dismissRelayWarning() { fun dismissRelayWarning() {
_isRelayListEmpty.value = false _state.update { it.copy(isRelayListEmpty = false) }
} }
fun refetchMsgRelays() { fun refetchMsgRelays() {
@@ -487,7 +476,8 @@ class AccountRepository(
scope.launch { scope.launch {
try { try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userRelayList.value = nostr.relays.getRelayList(user) val relayList = nostr.relays.getRelayList(user)
_state.update { it.copy(userRelayList = relayList) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -550,7 +540,8 @@ class AccountRepository(
scope.launch { scope.launch {
try { try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
_userMsgRelayList.value = nostr.relays.getMsgRelays(user) val msgRelays = nostr.relays.getMsgRelays(user)
_state.update { it.copy(userMsgRelayList = msgRelays) }
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -21,6 +22,7 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost import su.reya.coop.viewmodel.ErrorHost
@@ -34,15 +36,12 @@ data class ChatState(
class ChatRepository( class ChatRepository(
private val nostr: Nostr, private val nostr: Nostr,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState()) private val _state = MutableStateFlow(ChatState())
val state = _state.stateIn( val state = _state.asStateFlow()
scope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>( private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0, replay = 0,
@@ -134,10 +133,19 @@ class ChatRepository(
fun refreshChatRooms() { fun refreshChatRooms() {
scope.launch(defaultDispatcher) { scope.launch(defaultDispatcher) {
try { try {
val rooms = nostr.messages.getChatRooms() ?: emptySet() val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState -> _state.update { currentState ->
val newMap = currentState.rooms.toMutableMap() val newMap = currentState.rooms.toMutableMap()
rooms.forEach { room -> newMap[room.id] = room } dbRooms.forEach { dbRoom ->
val existing = newMap[dbRoom.id]
// Only update if the database version is newer or equal
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
// Preserve Ongoing kind if already marked as such in memory
val mergedKind =
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind)
}
}
currentState.copy(rooms = newMap) currentState.copy(rooms = newMap)
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
@@ -223,16 +231,24 @@ class ChatRepository(
val rooms = currentState.rooms.toMutableMap() val rooms = currentState.rooms.toMutableMap()
val existingRoom = rooms[roomId] val existingRoom = rooms[roomId]
val isFromMe = event.author() == currentUser
val newKind =
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
if (existingRoom == null) { if (existingRoom == null) {
// New room discovery // New room discovery
val newRoom = Room.new(event, currentUser, roomId) val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind)
rooms[newRoom.id] = newRoom rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals) // Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy( rooms[roomId] = existingRoom.copy(
lastMessage = event.content(), lastMessage = event.content(),
createdAt = event.createdAt() createdAt = event.createdAt(),
kind = newKind
) )
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else { } else {
// Don't update the room list state for older messages // Don't update the room list state for older messages
return@update currentState return@update currentState

View File

@@ -8,7 +8,9 @@ import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
class MediaRepository { class MediaRepository(
private val settingsRepository: SettingsRepository
) {
private val httpClient = HttpClient { private val httpClient = HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
@@ -25,12 +27,9 @@ class MediaRepository {
contentType: String? = "image/jpeg" contentType: String? = "image/jpeg"
): String? { ): String? {
return try { return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient) val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
val descriptor = blossom.upload( val blossom = BlossomClient(url, httpClient)
file = file, val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
contentType = contentType,
signer = signer,
)
descriptor?.url descriptor?.url
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e

View File

@@ -0,0 +1,61 @@
package su.reya.coop.repository
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 kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import su.reya.coop.AppStorage
import su.reya.coop.Settings
class SettingsRepository(
private val storage: AppStorage,
private val scope: CoroutineScope
) {
companion object {
private const val KEY_SETTINGS = "app_settings"
}
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _settings = MutableStateFlow(Settings())
val settings: StateFlow<Settings> = _settings.asStateFlow()
init {
scope.launch {
_settings.value = load()
}
}
fun update(transform: (Settings) -> Settings) {
scope.launch {
val newSettings = transform(_settings.value)
_settings.value = newSettings
save(newSettings)
}
}
private suspend fun save(settings: Settings) {
val jsonString = json.encodeToString(settings)
storage.set(KEY_SETTINGS, jsonString)
}
private suspend fun load(): Settings {
val jsonString = storage.get(KEY_SETTINGS)
return if (jsonString != null) {
try {
json.decodeFromString<Settings>(jsonString)
} catch (_: Exception) {
Settings()
}
} else {
Settings()
}
}
}

View File

@@ -3,8 +3,6 @@ package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile import su.reya.coop.Profile
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
@@ -16,10 +14,6 @@ class AccountViewModel(
val state: StateFlow<AccountState> = repository.state val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
val contactList: StateFlow<Set<PublicKey>> = repository.contactList
val isRelayListEmpty: StateFlow<Boolean> = repository.isRelayListEmpty
val userRelayList: StateFlow<Map<RelayUrl, RelayMetadata?>> = repository.userRelayList
val userMsgRelayList: StateFlow<List<RelayUrl>> = repository.userMsgRelayList
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout) fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
fun dismissNotificationBanner() = repository.dismissNotificationBanner() fun dismissNotificationBanner() = repository.dismissNotificationBanner()

View File

@@ -0,0 +1,16 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import su.reya.coop.Settings
import su.reya.coop.repository.SettingsRepository
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel() {
val settings: StateFlow<Settings> = repository.settings
fun update(transform: (Settings) -> Settings) {
repository.update(transform)
}
}