Compare commits
4 Commits
v0.2.5
...
6bb4ef2805
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bb4ef2805 | |||
| e4e73da229 | |||
| 255c840847 | |||
| 9defad522c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ captures
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
.artifacts/
|
||||
@@ -4,7 +4,6 @@ import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -40,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
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.ChatViewModel
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||
error("No ProfileCache provided")
|
||||
}
|
||||
|
||||
val LocalSettings = staticCompositionLocalOf<Settings> {
|
||||
error("No Settings provided")
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
error("No SnackbarHostState provided")
|
||||
}
|
||||
@@ -80,6 +85,7 @@ fun App(
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
) {
|
||||
val viewModelFactory = remember {
|
||||
object : ViewModelProvider.Factory {
|
||||
@@ -93,6 +99,10 @@ fun App(
|
||||
accountRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -103,6 +113,7 @@ fun App(
|
||||
|
||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
@@ -114,19 +125,24 @@ fun App(
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val signerRequired = accountState.signerRequired
|
||||
|
||||
// Get the settings
|
||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// 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
|
||||
val colorScheme = when {
|
||||
// Enable the dynamic color scheme for Android 12+
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||
context
|
||||
)
|
||||
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
// When dark mode is enabled, use the dark color scheme
|
||||
darkMode -> darkColorScheme()
|
||||
@@ -134,10 +150,6 @@ fun App(
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size > 1) {
|
||||
navigator.goBack()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
accountViewModel.errorEvents.collect { message ->
|
||||
@@ -195,11 +207,11 @@ fun App(
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalProfileCache provides profileCache,
|
||||
LocalSettings provides settings,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavigator provides navigator,
|
||||
LocalScanResult provides qrScanResult,
|
||||
) {
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
|
||||
@@ -14,6 +14,7 @@ import su.reya.coop.nostr.NostrManager
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
import su.reya.coop.repository.ChatRepository
|
||||
import su.reya.coop.repository.MediaRepository
|
||||
import su.reya.coop.repository.SettingsRepository
|
||||
import su.reya.coop.viewmodel.ProfileCache
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -25,16 +26,28 @@ class MainActivity : ComponentActivity() {
|
||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||
private val scope = MainScope()
|
||||
|
||||
private val settingsRepository by lazy {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
SettingsRepository(storage, scope)
|
||||
}
|
||||
|
||||
private val accountRepository by lazy {
|
||||
val storage = AppStore(this@MainActivity)
|
||||
val mediaRepository = MediaRepository()
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
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 {
|
||||
val mediaRepository = MediaRepository()
|
||||
ChatRepository(NostrManager.instance, mediaRepository, scope)
|
||||
val mediaRepository = MediaRepository(settingsRepository)
|
||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -82,6 +95,7 @@ class MainActivity : ComponentActivity() {
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ fun ContactListScreen(
|
||||
) {
|
||||
val navigator = LocalNavigator.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 contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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_request
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
@@ -124,13 +126,13 @@ fun HomeScreen(
|
||||
val userProfile by accountViewModel.currentUserProfile.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 isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed = accountState.isNotificationBannerDismissed
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -249,7 +251,9 @@ fun HomeScreen(
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||
AnimatedVisibility(
|
||||
visible = !isNotificationEnabled && !isBannerDismissed,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -372,10 +376,14 @@ fun HomeScreen(
|
||||
}
|
||||
|
||||
items(ongoing, key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.animateItem()
|
||||
) {
|
||||
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 secondRoom = requests.getOrNull(1)
|
||||
|
||||
val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
val firstRoomState by remember(firstRoom?.id) {
|
||||
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val secondRoomState by remember(secondRoom?.id) {
|
||||
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
val supportingText = when {
|
||||
total == 1 -> {
|
||||
total == 1 && firstRoom != null -> {
|
||||
val message = firstRoom.lastMessage ?: ""
|
||||
"${firstRoomState.name}: $message"
|
||||
}
|
||||
@@ -695,7 +706,8 @@ fun NewRequests(requests: List<Room>) {
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
|
||||
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
|
||||
@@ -101,13 +101,6 @@ fun ImportScreen(viewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Show import errors via snackbar
|
||||
LaunchedEffect(accountState.importError) {
|
||||
accountState.importError?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
|
||||
@@ -75,7 +75,8 @@ fun NewChatScreen(
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.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("") }
|
||||
|
||||
val createGroup = remember { mutableStateOf(false) }
|
||||
@@ -185,8 +186,8 @@ fun NewChatScreen(
|
||||
)
|
||||
},
|
||||
text = { Text("Next") },
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 expressiveFont = getExpressiveFontFamily()
|
||||
|
||||
|
||||
@@ -100,8 +100,9 @@ fun RelayScreen(viewModel: AccountViewModel) {
|
||||
viewModel.loadCurrentUserMsgRelayList()
|
||||
}
|
||||
|
||||
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
|
||||
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadedRelayList = accountState.userRelayList
|
||||
val loadedMsgRelayList = accountState.userMsgRelayList
|
||||
|
||||
LaunchedEffect(loadedRelayList) {
|
||||
if (loadedRelayList.isNotEmpty()) {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -177,11 +172,7 @@ fun ChatMessage(
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isMessageClicked,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
if (isMessageClicked) {
|
||||
Text(
|
||||
text = model.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -146,8 +146,9 @@ fun ChatScreen(
|
||||
val groupedMessages =
|
||||
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
|
||||
|
||||
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
val roomState by remember(id, currentUser?.publicKey) {
|
||||
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
|
||||
}.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
|
||||
@@ -295,7 +296,9 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
replyPreview?.let { ReplyPreview(it, model.isMine) }
|
||||
@@ -380,8 +383,10 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
else -> {
|
||||
replyingTo?.let {
|
||||
ReplyBox(it) { replyingTo = null }
|
||||
AnimatedVisibility(visible = replyingTo != null) {
|
||||
replyingTo?.let {
|
||||
ReplyBox(it) { replyingTo = null }
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
@@ -420,7 +425,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = selectedMessage != null,
|
||||
enter = fadeIn(),
|
||||
|
||||
22
shared/src/commonMain/kotlin/su/reya/coop/Settings.kt
Normal file
22
shared/src/commonMain/kotlin/su/reya/coop/Settings.kt
Normal 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
|
||||
}
|
||||
@@ -47,13 +47,17 @@ data class AccountState(
|
||||
val signerRequired: Boolean? = null,
|
||||
val isNotificationBannerDismissed: 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(
|
||||
private val nostr: Nostr,
|
||||
private val storage: AppStorage,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
@@ -72,23 +76,9 @@ class AccountRepository(
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
||||
.flatMapLatest { pubkey ->
|
||||
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
|
||||
}
|
||||
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(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 {
|
||||
checkNotificationBannerDismissedStatus()
|
||||
login()
|
||||
@@ -116,7 +106,7 @@ class AccountRepository(
|
||||
} ?: emptyList()
|
||||
|
||||
// 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) {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true, importError = null) }
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
try {
|
||||
val (signer, decryptedSecret) = createSigner(secret, password)
|
||||
|
||||
@@ -239,14 +229,14 @@ class AccountRepository(
|
||||
_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) }
|
||||
_state.update { it.copy(isImporting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun connectExternalSigner() {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true, importError = null) }
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
try {
|
||||
val handler =
|
||||
externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
@@ -276,7 +266,7 @@ class AccountRepository(
|
||||
_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) }
|
||||
_state.update { it.copy(isImporting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,7 +278,7 @@ class AccountRepository(
|
||||
contentType: String? = null
|
||||
) {
|
||||
scope.launch {
|
||||
_state.update { it.copy(isImporting = true, importError = null) }
|
||||
_state.update { it.copy(isImporting = true) }
|
||||
try {
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
@@ -307,7 +297,7 @@ class AccountRepository(
|
||||
_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) }
|
||||
_state.update { it.copy(isImporting = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -355,14 +345,13 @@ class AccountRepository(
|
||||
scope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||
_contactList.value = contacts.toSet()
|
||||
_state.update { it.copy(contactList = contacts.toSet()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
_contactList.value = emptySet()
|
||||
_isRelayListEmpty.value = false
|
||||
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
|
||||
}
|
||||
|
||||
fun addContact(address: String) {
|
||||
@@ -378,12 +367,12 @@ class AccountRepository(
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (pubkey in _contactList.value) return@launch
|
||||
if (pubkey in _state.value.contactList) return@launch
|
||||
|
||||
try {
|
||||
val updated = _contactList.value + pubkey
|
||||
val updated = _state.value.contactList + pubkey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_contactList.update { it + pubkey }
|
||||
_state.update { it.copy(contactList = it.contactList + pubkey) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
@@ -392,12 +381,12 @@ class AccountRepository(
|
||||
|
||||
fun removeContact(publicKey: PublicKey) {
|
||||
scope.launch {
|
||||
if (publicKey !in _contactList.value) return@launch
|
||||
if (publicKey !in _state.value.contactList) return@launch
|
||||
|
||||
try {
|
||||
val updated = _contactList.value - publicKey
|
||||
val updated = _state.value.contactList - publicKey
|
||||
nostr.profiles.setContactList(updated.toList())
|
||||
_contactList.update { it - publicKey }
|
||||
_state.update { it.copy(contactList = it.contactList - publicKey) }
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
@@ -460,7 +449,7 @@ class AccountRepository(
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_isRelayListEmpty.value = false
|
||||
_state.update { it.copy(isRelayListEmpty = false) }
|
||||
}
|
||||
|
||||
fun refetchMsgRelays() {
|
||||
@@ -487,7 +476,8 @@ class AccountRepository(
|
||||
scope.launch {
|
||||
try {
|
||||
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) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
@@ -550,7 +540,8 @@ class AccountRepository(
|
||||
scope.launch {
|
||||
try {
|
||||
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) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -21,6 +22,7 @@ import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.nostr.Nostr
|
||||
import su.reya.coop.roomId
|
||||
import su.reya.coop.viewmodel.ErrorHost
|
||||
@@ -34,15 +36,12 @@ data class ChatState(
|
||||
class ChatRepository(
|
||||
private val nostr: Nostr,
|
||||
private val mediaRepository: MediaRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val _state = MutableStateFlow(ChatState())
|
||||
val state = _state.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
ChatState()
|
||||
)
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
|
||||
replay = 0,
|
||||
@@ -134,10 +133,19 @@ class ChatRepository(
|
||||
fun refreshChatRooms() {
|
||||
scope.launch(defaultDispatcher) {
|
||||
try {
|
||||
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||
_state.update { currentState ->
|
||||
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)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -223,16 +231,24 @@ class ChatRepository(
|
||||
val rooms = currentState.rooms.toMutableMap()
|
||||
val existingRoom = rooms[roomId]
|
||||
|
||||
val isFromMe = event.author() == currentUser
|
||||
val newKind =
|
||||
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
|
||||
|
||||
if (existingRoom == null) {
|
||||
// New room discovery
|
||||
val newRoom = Room.new(event, currentUser, roomId)
|
||||
val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind)
|
||||
rooms[newRoom.id] = newRoom
|
||||
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
||||
// Only update preview if message is newer (handles sync/late arrivals)
|
||||
rooms[roomId] = existingRoom.copy(
|
||||
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 {
|
||||
// Don't update the room list state for older messages
|
||||
return@update currentState
|
||||
|
||||
@@ -8,7 +8,9 @@ import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
|
||||
class MediaRepository {
|
||||
class MediaRepository(
|
||||
private val settingsRepository: SettingsRepository
|
||||
) {
|
||||
private val httpClient = HttpClient {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
@@ -25,12 +27,9 @@ class MediaRepository {
|
||||
contentType: String? = "image/jpeg"
|
||||
): String? {
|
||||
return try {
|
||||
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||
val descriptor = blossom.upload(
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = signer,
|
||||
)
|
||||
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
|
||||
val blossom = BlossomClient(url, httpClient)
|
||||
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
|
||||
descriptor?.url
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package su.reya.coop.viewmodel
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.Profile
|
||||
import su.reya.coop.repository.AccountRepository
|
||||
@@ -16,10 +14,6 @@ class AccountViewModel(
|
||||
val state: StateFlow<AccountState> = repository.state
|
||||
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
|
||||
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 dismissNotificationBanner() = repository.dismissNotificationBanner()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user