Compare commits
7 Commits
43618533a5
...
v0.2.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 44c743d039 | |||
| a124dc4cc4 | |||
| 4a8134dec0 | |||
| 07085449a4 | |||
| 9defad522c | |||
| 47196dbea4 | |||
| 30191170f1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ captures
|
||||
!*.xcworkspace/contents.xcworkspacedata
|
||||
**/xcshareddata/WorkspaceSettings.xcsettings
|
||||
node_modules/
|
||||
.artifacts/
|
||||
@@ -69,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.2.4"
|
||||
versionName = "0.2.6"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {
|
||||
private val manager =
|
||||
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
|
||||
private val _isMobileData = MutableStateFlow(checkIsMobileData())
|
||||
override val isMobileData: StateFlow<Boolean> = _isMobileData.asStateFlow()
|
||||
|
||||
init {
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
manager.registerNetworkCallback(
|
||||
networkRequest,
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
_isMobileData.value = checkIsMobileData()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
_isMobileData.value = checkIsMobileData()
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
_isMobileData.value = checkIsMobileData()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun checkIsMobileData(): Boolean {
|
||||
val activeNetwork = manager.activeNetwork ?: return false
|
||||
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -51,17 +51,27 @@ import su.reya.coop.screens.ProfileScreen
|
||||
import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.RequestListScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.SettingsScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
import su.reya.coop.screens.chat.ChatScreen
|
||||
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 LocalConnectivity = staticCompositionLocalOf<Boolean> {
|
||||
false
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
error("No SnackbarHostState provided")
|
||||
}
|
||||
@@ -80,6 +90,8 @@ fun App(
|
||||
profileCache: ProfileCache,
|
||||
accountRepository: AccountRepository,
|
||||
chatRepository: ChatRepository,
|
||||
settingsRepository: SettingsRepository,
|
||||
connectivityMonitor: ConnectivityMonitor,
|
||||
) {
|
||||
val viewModelFactory = remember {
|
||||
object : ViewModelProvider.Factory {
|
||||
@@ -93,6 +105,10 @@ fun App(
|
||||
accountRepository
|
||||
)
|
||||
|
||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
||||
settingsRepository
|
||||
)
|
||||
|
||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@@ -103,6 +119,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 +131,27 @@ fun App(
|
||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||
val signerRequired = accountState.signerRequired
|
||||
|
||||
// Get the settings
|
||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
||||
|
||||
// Get connectivity status
|
||||
val isMobileData by connectivityMonitor.isMobileData.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 +159,6 @@ fun App(
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
BackHandler(enabled = backStack.size > 1) {
|
||||
navigator.goBack()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
accountViewModel.errorEvents.collect { message ->
|
||||
@@ -195,11 +216,12 @@ fun App(
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalProfileCache provides profileCache,
|
||||
LocalSettings provides settings,
|
||||
LocalConnectivity provides isMobileData,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavigator provides navigator,
|
||||
LocalScanResult provides qrScanResult,
|
||||
) {
|
||||
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = {
|
||||
@@ -272,6 +294,9 @@ fun App(
|
||||
entry<Screen.Relay> {
|
||||
RelayScreen(accountViewModel)
|
||||
}
|
||||
entry<Screen.Settings> {
|
||||
SettingsScreen(settingsViewModel)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,30 @@ class MainActivity : ComponentActivity() {
|
||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||
private val scope = MainScope()
|
||||
|
||||
private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) }
|
||||
|
||||
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 +97,8 @@ class MainActivity : ComponentActivity() {
|
||||
profileCache = profileCache,
|
||||
accountRepository = accountRepository,
|
||||
chatRepository = chatRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
connectivityMonitor = connectivityMonitor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,4 +58,7 @@ sealed interface Screen : NavKey {
|
||||
|
||||
@Serializable
|
||||
data object Relay : Screen
|
||||
|
||||
@Serializable
|
||||
data object Settings : Screen
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -28,8 +28,6 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||
|
||||
class NostrForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
private val nostr by lazy { NostrManager.instance }
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
@@ -191,4 +189,13 @@ class NostrForegroundService : Service() {
|
||||
super.onDestroy()
|
||||
serviceScope.cancel()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
|
||||
Log.d("Coop", "Stopping service on task removed because notifications are disabled")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -24,6 +25,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -86,6 +89,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 +128,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 +253,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 +378,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 +630,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"
|
||||
}
|
||||
@@ -644,27 +657,39 @@ fun NewRequests(requests: List<Room>) {
|
||||
else -> ""
|
||||
}
|
||||
|
||||
val totalUnread = requests.sumOf { it.unreadCount }
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable {
|
||||
navigator.navigate(Screen.RequestList)
|
||||
},
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (totalUnread > 0) {
|
||||
Badge {
|
||||
Text(totalUnread.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_request),
|
||||
contentDescription = "Requests",
|
||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_request),
|
||||
contentDescription = "Requests",
|
||||
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,14 +697,19 @@ fun NewRequests(requests: List<Room>) {
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = "Requests",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
)
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (supportingText.isNotEmpty()) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
||||
),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -695,28 +725,41 @@ fun NewRequests(requests: List<Room>) {
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val profileCache = LocalProfileCache.current
|
||||
val roomState by room.uiStateFlow(profileCache)
|
||||
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
|
||||
.collectAsStateWithLifecycle(RoomUiState())
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (room.unreadCount > 0) {
|
||||
Badge {
|
||||
Text(room.unreadCount.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||
}
|
||||
},
|
||||
headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = roomState.name,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = room.createdAt.ago(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -724,7 +767,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
if (!room.lastMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = room.lastMessage ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (room.unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
|
||||
),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -749,7 +795,7 @@ fun BottomMenuList(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { }
|
||||
"Settings" to { navigator.navigate(Screen.Settings) }
|
||||
)
|
||||
|
||||
Column(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -397,7 +398,7 @@ fun ContactListItem(
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = profile?.name ?: "",
|
||||
text = profile?.name ?: "Unknown",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -95,14 +95,15 @@ fun RelayScreen(viewModel: AccountViewModel) {
|
||||
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val accountState by viewModel.state.collectAsStateWithLifecycle()
|
||||
val loadedRelayList = accountState.userRelayList
|
||||
val loadedMsgRelayList = accountState.userMsgRelayList
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadCurrentUserRelayList()
|
||||
viewModel.loadCurrentUserMsgRelayList()
|
||||
}
|
||||
|
||||
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
|
||||
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(loadedRelayList) {
|
||||
if (loadedRelayList.isNotEmpty()) {
|
||||
relayList.clear()
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.MediaConfig
|
||||
import su.reya.coop.Theme
|
||||
import su.reya.coop.viewmodel.SettingsViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||
val navigator = LocalNavigator.current
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val settings by viewModel.settings.collectAsState()
|
||||
|
||||
var showThemeDialog by remember { mutableStateOf(false) }
|
||||
var showMediaDialog by remember { mutableStateOf(false) }
|
||||
var showBlossomDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Settings",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "General",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
||||
) {
|
||||
SegmentedListItem(
|
||||
onClick = { viewModel.update { it.copy(screening = !it.screening) } },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 4),
|
||||
content = { Text("Screening") },
|
||||
supportingContent = { Text("Filter unknown contacts") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = settings.screening,
|
||||
onCheckedChange = { viewModel.update { s -> s.copy(screening = it) } }
|
||||
)
|
||||
}
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = { showMediaDialog = true },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 4),
|
||||
content = { Text("Media Preview") },
|
||||
supportingContent = {
|
||||
Text(
|
||||
when (settings.media) {
|
||||
MediaConfig.Disabled -> "Disabled"
|
||||
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
|
||||
MediaConfig.AlwaysEnabled -> "Always Enabled"
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = { showBlossomDialog = true },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 2, count = 4),
|
||||
content = { Text("Blossom Server") },
|
||||
supportingContent = { Text(settings.blossomServer ?: "Default") }
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = {
|
||||
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 3, count = 4),
|
||||
content = { Text("Notifications") },
|
||||
supportingContent = { Text("System notification settings") }
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Appearance",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
|
||||
) {
|
||||
SegmentedListItem(
|
||||
onClick = { showThemeDialog = true },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2),
|
||||
content = { Text("Theme") },
|
||||
supportingContent = { Text(settings.theme.name) }
|
||||
)
|
||||
SegmentedListItem(
|
||||
onClick = { viewModel.update { it.copy(dynamicColor = !it.dynamicColor) } },
|
||||
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2),
|
||||
content = { Text("Dynamic Color") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = settings.dynamicColor,
|
||||
onCheckedChange = {
|
||||
viewModel.update { s -> s.copy(dynamicColor = it) }
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showThemeDialog) {
|
||||
OptionDialog(
|
||||
title = "Select Theme",
|
||||
options = Theme.entries.map { it.name },
|
||||
selected = settings.theme.name,
|
||||
onSelected = { name ->
|
||||
viewModel.update { it.copy(theme = Theme.valueOf(name)) }
|
||||
showThemeDialog = false
|
||||
},
|
||||
onDismiss = { showThemeDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showMediaDialog) {
|
||||
OptionDialog(
|
||||
title = "Media Preview",
|
||||
options = listOf("Disabled", "Disabled for Mobile Data", "Always Enabled"),
|
||||
selected = when (settings.media) {
|
||||
MediaConfig.Disabled -> "Disabled"
|
||||
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
|
||||
MediaConfig.AlwaysEnabled -> "Always Enabled"
|
||||
},
|
||||
onSelected = { choice ->
|
||||
val newConfig = when (choice) {
|
||||
"Disabled" -> MediaConfig.Disabled
|
||||
"Disabled for Mobile Data" -> MediaConfig.DisabledForMobileData
|
||||
else -> MediaConfig.AlwaysEnabled
|
||||
}
|
||||
viewModel.update { it.copy(media = newConfig) }
|
||||
showMediaDialog = false
|
||||
},
|
||||
onDismiss = { showMediaDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
if (showBlossomDialog) {
|
||||
var text by remember { mutableStateOf(settings.blossomServer ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showBlossomDialog = false },
|
||||
title = { Text("Blossom Server URL") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("https://...") },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.update { it.copy(blossomServer = text.ifBlank { null }) }
|
||||
showBlossomDialog = false
|
||||
}) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showBlossomDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionDialog(
|
||||
title: String,
|
||||
options: List<String>,
|
||||
selected: String,
|
||||
onSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(modifier = Modifier.selectableGroup()) {
|
||||
options.forEach { text ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = (text == selected),
|
||||
onClick = { onSelected(text) },
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(selected = (text == selected), onClick = null)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -43,6 +38,9 @@ import coil3.compose.AsyncImage
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalConnectivity
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.MediaConfig
|
||||
import su.reya.coop.URL_REGEX
|
||||
import su.reya.coop.formatAsTime
|
||||
import su.reya.coop.isImageUrl
|
||||
@@ -61,14 +59,27 @@ data class MessageModel(
|
||||
|
||||
@Composable
|
||||
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
||||
return remember(event, currentUser) {
|
||||
val settings = LocalSettings.current
|
||||
val isMobileData = LocalConnectivity.current
|
||||
|
||||
return remember(event, currentUser, settings, isMobileData) {
|
||||
val id = event.ensureId().id()!!
|
||||
val isMine = currentUser == event.author()
|
||||
val content = event.content()
|
||||
val replyEventIds = event.tags().eventIds()
|
||||
|
||||
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
||||
val cleanedContent = content.removeImageUrls()
|
||||
val showMedia = when (settings.media) {
|
||||
MediaConfig.AlwaysEnabled -> true
|
||||
MediaConfig.Disabled -> false
|
||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
||||
}
|
||||
|
||||
val images = if (showMedia) {
|
||||
URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
val cleanedContent = if (showMedia) content.removeImageUrls() else content
|
||||
|
||||
val annotatedString = buildAnnotatedString {
|
||||
var lastIndex = 0
|
||||
@@ -177,11 +188,7 @@ fun ChatMessage(
|
||||
)
|
||||
}
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = isMessageClicked,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
if (isMessageClicked) {
|
||||
Text(
|
||||
text = model.timestamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
|
||||
@@ -93,6 +93,7 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalProfileCache
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomUiState
|
||||
@@ -115,6 +116,7 @@ fun ChatScreen(
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val navigator = LocalNavigator.current
|
||||
val profileCache = LocalProfileCache.current
|
||||
val settings = LocalSettings.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -146,8 +148,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) }
|
||||
@@ -264,7 +267,7 @@ fun ChatScreen(
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (requireScreening) {
|
||||
if (requireScreening && settings.screening) {
|
||||
room?.let { ScreenerCard(accountViewModel, it) }
|
||||
}
|
||||
|
||||
@@ -295,7 +298,9 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateItem(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
replyPreview?.let { ReplyPreview(it, model.isMine) }
|
||||
@@ -346,7 +351,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
when (requireScreening) {
|
||||
when (requireScreening && settings.screening) {
|
||||
true -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -380,8 +385,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 +427,6 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = selectedMessage != null,
|
||||
enter = fadeIn(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.avatar
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalConnectivity
|
||||
import su.reya.coop.LocalSettings
|
||||
import su.reya.coop.MediaConfig
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
@@ -22,17 +26,36 @@ fun Avatar(
|
||||
size: Dp = 48.dp,
|
||||
shape: Shape = CircleShape
|
||||
) {
|
||||
val settings = LocalSettings.current
|
||||
val isMobileData = LocalConnectivity.current
|
||||
val placeholder = painterResource(Res.drawable.avatar)
|
||||
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = placeholder,
|
||||
error = placeholder,
|
||||
placeholder = placeholder
|
||||
)
|
||||
val showMedia = when (settings.media) {
|
||||
MediaConfig.AlwaysEnabled -> true
|
||||
MediaConfig.Disabled -> false
|
||||
MediaConfig.DisabledForMobileData -> !isMobileData
|
||||
}
|
||||
|
||||
if (showMedia) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = placeholder,
|
||||
error = placeholder,
|
||||
placeholder = placeholder
|
||||
)
|
||||
} else {
|
||||
Image(
|
||||
painter = placeholder,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ConnectivityMonitor {
|
||||
val isMobileData: StateFlow<Boolean>
|
||||
}
|
||||
@@ -30,7 +30,8 @@ data class Room(
|
||||
val subject: String?,
|
||||
val members: Set<PublicKey>,
|
||||
val kind: RoomKind = RoomKind.default(),
|
||||
val lastMessage: String? = null
|
||||
val lastMessage: String? = null,
|
||||
val unreadCount: Int = 0
|
||||
) : Comparable<Room> {
|
||||
override fun compareTo(other: Room): Int {
|
||||
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
|
||||
@@ -112,13 +113,8 @@ fun UnsignedEvent.roomId(): Long {
|
||||
val pubkeys: MutableList<PublicKey> = mutableListOf()
|
||||
pubkeys.add(this.author())
|
||||
pubkeys.addAll(this.tags().publicKeys())
|
||||
|
||||
// Sort and hash the list of public keys
|
||||
val sortedUniqueKeys = pubkeys
|
||||
.distinctBy { it.toBech32() }
|
||||
.sortedBy { it.toBech32() }
|
||||
|
||||
return sortedUniqueKeys.hashCode().toLong()
|
||||
|
||||
return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
|
||||
}
|
||||
|
||||
fun Timestamp.formatAsTime(): String {
|
||||
|
||||
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: MediaConfig = MediaConfig.AlwaysEnabled,
|
||||
val screening: Boolean = true,
|
||||
val blossomServer: String? = "https://blossom.band",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class Theme {
|
||||
Light, Dark, System
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class MediaConfig {
|
||||
Disabled, DisabledForMobileData, AlwaysEnabled
|
||||
}
|
||||
@@ -39,12 +39,11 @@ class MessageManager(private val nostr: Nostr) {
|
||||
private val client: Client? get() = nostr.client
|
||||
private val signer: UniversalSigner get() = nostr.signer
|
||||
|
||||
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||
|
||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||
val messageSyncState = _messageSyncState.asStateFlow()
|
||||
|
||||
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||
|
||||
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||
_messageSyncState.update(update)
|
||||
}
|
||||
@@ -73,6 +72,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
target = ReqTarget.manual(target),
|
||||
id = "gift-wraps"
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||
}
|
||||
@@ -225,6 +226,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
// Filter out events without public keys (receivers)
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||
}
|
||||
@@ -248,6 +251,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
connectMsgRelays(event)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||
}
|
||||
@@ -327,9 +332,6 @@ class MessageManager(private val nostr: Nostr) {
|
||||
)
|
||||
|
||||
if (output != null) {
|
||||
// Keep track of sent events
|
||||
sentEvents[output.id] = emptyList()
|
||||
|
||||
// Keep track of rumor IDs
|
||||
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||
rumorMap[id] = output.id
|
||||
@@ -340,6 +342,8 @@ class MessageManager(private val nostr: Nostr) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Client
|
||||
import rust.nostr.sdk.ClientBuilder
|
||||
@@ -108,14 +108,6 @@ class Nostr(
|
||||
relays.connectBootstrapRelays()
|
||||
}
|
||||
|
||||
suspend fun reconnect() {
|
||||
relays.reconnect()
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
relays.disconnect()
|
||||
}
|
||||
|
||||
suspend fun prune() {
|
||||
try {
|
||||
client?.database()?.wipe()
|
||||
@@ -162,7 +154,10 @@ class Nostr(
|
||||
|
||||
// Trigger new message notification
|
||||
if (rumor != null) {
|
||||
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
|
||||
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
|
||||
|
||||
if (isNew && !isSelfMessage) {
|
||||
onNewMessage(rumor)
|
||||
}
|
||||
}
|
||||
@@ -182,8 +177,6 @@ class Nostr(
|
||||
|
||||
when (notification) {
|
||||
is ClientNotification.Message -> {
|
||||
val relayUrl = notification.relayUrl
|
||||
|
||||
when (val message = notification.message.asEnum()) {
|
||||
is RelayMessageEnum.EventMsg -> {
|
||||
val event = message.event
|
||||
@@ -237,14 +230,6 @@ class Nostr(
|
||||
}
|
||||
}
|
||||
|
||||
is RelayMessageEnum.Ok -> {
|
||||
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||
val currentRelays =
|
||||
messages.sentEvents[message.eventId] ?: emptyList()
|
||||
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
/* Ignore other message types */
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
|
||||
class ProfileManager(private val nostr: Nostr) {
|
||||
@@ -80,6 +81,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||
}
|
||||
@@ -92,6 +95,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||
|
||||
client?.sync(filter, relays)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to sync mutual contacts: ${e.message}")
|
||||
}
|
||||
@@ -174,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
)
|
||||
|
||||
return newMetadata
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||
}
|
||||
@@ -186,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||
|
||||
Metadata.fromJson(event.content())
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get latest metadata: ${e.message}")
|
||||
null
|
||||
@@ -208,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get all cache metadata: ${e.message}")
|
||||
return emptyMap()
|
||||
@@ -232,6 +243,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||
}
|
||||
@@ -247,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||
}
|
||||
@@ -258,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val bodyString: String = response.body()
|
||||
|
||||
return Nip05Profile.fromJson(address, bodyString)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||
}
|
||||
@@ -269,6 +286,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val profile = profileFromAddress(httpClient, address)
|
||||
|
||||
return profile.publicKey()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||
}
|
||||
@@ -304,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||
}
|
||||
@@ -323,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
)
|
||||
|
||||
return events?.first()?.createdAt()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||
}
|
||||
@@ -340,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||
|
||||
return pubkeys.contains(pubkey)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||
}
|
||||
@@ -361,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) {
|
||||
}
|
||||
|
||||
return contacts.toSet()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ class RelayManager(private val nostr: Nostr) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||
suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||
try {
|
||||
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||
|
||||
@@ -143,7 +143,7 @@ class RelayManager(private val nostr: Nostr) {
|
||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||
try {
|
||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -511,7 +501,7 @@ class AccountRepository(
|
||||
val relays = currentUserRelayListInternal().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.WRITE
|
||||
|
||||
nostr.relays.setRelaylist(relays)
|
||||
nostr.relays.setRelayList(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
@@ -525,7 +515,7 @@ class AccountRepository(
|
||||
val relays = currentUserRelayListInternal().toMutableMap()
|
||||
relays[relayUrl] = RelayMetadata.READ
|
||||
|
||||
nostr.relays.setRelaylist(relays)
|
||||
nostr.relays.setRelayList(relays)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
@@ -539,7 +529,7 @@ class AccountRepository(
|
||||
val relays = currentUserRelayListInternal().toMutableMap()
|
||||
relays.remove(relayUrl)
|
||||
|
||||
nostr.relays.setRelaylist(relays)
|
||||
nostr.relays.setRelayList(relays)
|
||||
} 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,
|
||||
@@ -51,13 +50,16 @@ class ChatRepository(
|
||||
)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||
val chatRooms = state
|
||||
.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||
|
||||
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||
val isSyncing = nostr.messages.messageSyncState
|
||||
.map { it.isSyncing }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
||||
val isPartialProcessedGiftWrap = state
|
||||
.map { it.isPartialProcessedGiftWrap }
|
||||
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
init {
|
||||
@@ -82,18 +84,7 @@ class ChatRepository(
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _state.value.rooms[roomId]
|
||||
|
||||
if (existingRoom == null) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||
val newRoom = Room.new(event, currentUser)
|
||||
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
|
||||
_newEvents.tryEmit(event)
|
||||
updateRoomState(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,13 +130,39 @@ class ChatRepository(
|
||||
return _state.value.rooms[id]
|
||||
}
|
||||
|
||||
fun markAsRead(roomId: Long) {
|
||||
_state.update { currentState ->
|
||||
val rooms = currentState.rooms.toMutableMap()
|
||||
val room = rooms[roomId]
|
||||
if (room != null && room.unreadCount > 0) {
|
||||
rooms[roomId] = room.copy(unreadCount = 0)
|
||||
currentState.copy(rooms = rooms)
|
||||
} else {
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 and unreadCount status if already marked as such in memory
|
||||
val mergedKind =
|
||||
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
|
||||
val mergedUnreadCount = existing?.unreadCount ?: 0
|
||||
newMap[dbRoom.id] = dbRoom.copy(
|
||||
kind = mergedKind,
|
||||
unreadCount = mergedUnreadCount
|
||||
)
|
||||
}
|
||||
}
|
||||
currentState.copy(rooms = newMap)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
@@ -194,9 +211,10 @@ class ChatRepository(
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies,
|
||||
onRumorCreated = { event ->
|
||||
updateRoomList(roomId, event)
|
||||
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) }
|
||||
onRumorCreated = {
|
||||
scope.launch(defaultDispatcher) {
|
||||
updateRoomState(it, roomId)
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
@@ -223,26 +241,44 @@ class ChatRepository(
|
||||
}
|
||||
}
|
||||
|
||||
fun isMessageSent(id: EventId): Boolean {
|
||||
val giftWrapId = nostr.messages.rumorMap[id]
|
||||
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
|
||||
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||
|
||||
if (giftWrapId != null) {
|
||||
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||
return isSent
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||
_state.update { currentState ->
|
||||
val room = currentState.rooms[roomId] ?: return@update currentState
|
||||
val updatedRoom = room.copy(
|
||||
lastMessage = newMessage.content(),
|
||||
createdAt = newMessage.createdAt()
|
||||
)
|
||||
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom))
|
||||
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).copy(
|
||||
kind = newKind,
|
||||
unreadCount = if (isFromMe) 0 else 1
|
||||
)
|
||||
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(),
|
||||
kind = newKind,
|
||||
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
|
||||
)
|
||||
} 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
|
||||
}
|
||||
currentState.copy(rooms = rooms)
|
||||
}
|
||||
|
||||
// Notify subscribers about the new event (for the active chat screen)
|
||||
_newEvents.tryEmit(event)
|
||||
}
|
||||
|
||||
fun resetInternalState() {
|
||||
|
||||
@@ -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
|
||||
@@ -13,14 +11,9 @@ import su.reya.coop.repository.AccountState
|
||||
class AccountViewModel(
|
||||
private val repository: AccountRepository,
|
||||
) : ViewModel(), ErrorHost by repository {
|
||||
|
||||
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()
|
||||
|
||||
@@ -42,6 +42,8 @@ class ChatScreenViewModel(
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages.distinctBy { it.id() })
|
||||
loading = false
|
||||
// Mark the room as read once messages are loaded
|
||||
chatRepository.markAsRead(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +57,7 @@ class ChatScreenViewModel(
|
||||
if (event.roomId() == id) {
|
||||
if (messages.none { it.id() == event.id() }) {
|
||||
messages.add(0, event)
|
||||
chatRepository.markAsRead(id)
|
||||
}
|
||||
} else {
|
||||
newOtherMessages++
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package su.reya.coop.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
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.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.Profile
|
||||
@@ -19,27 +18,41 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class ProfileCache(
|
||||
private val nostr: Nostr,
|
||||
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
) : ErrorHost by createErrorHost() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val profilesMutex = Mutex()
|
||||
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||
private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
|
||||
private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())
|
||||
private val seenPublicKeys = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
init {
|
||||
scope.launch { runObserver() }
|
||||
scope.launch { runMetadataBatching() }
|
||||
scope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
loadCacheMetadata()
|
||||
try {
|
||||
runObserver()
|
||||
} catch (e: Exception) {
|
||||
showError("Metadata observer failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
runMetadataBatching()
|
||||
} catch (e: Exception) {
|
||||
showError("Metadata batching failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
nostr.waitUntilInitialized()
|
||||
getCachedMetadata()
|
||||
} catch (e: Exception) {
|
||||
showError("Failed to load initial cache: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runObserver() = coroutineScope {
|
||||
launch {
|
||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||
}
|
||||
private suspend fun runObserver() {
|
||||
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,37 +70,69 @@ class ProfileCache(
|
||||
batch.add(nextKey)
|
||||
}
|
||||
|
||||
nostr.profiles.fetchMetadataBatch(batch.toList())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadCacheMetadata() {
|
||||
val cache = nostr.profiles.getAllCacheMetadata()
|
||||
|
||||
profilesMutex.withLock {
|
||||
cache.forEach { (pubkey, metadata) ->
|
||||
val profile = Profile(pubkey, metadata)
|
||||
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||
seenPublicKeys.add(pubkey)
|
||||
try {
|
||||
nostr.profiles.fetchMetadataBatch(batch.toList())
|
||||
} catch (e: Exception) {
|
||||
// Allow these keys to be requested again since the fetch failed
|
||||
seenPublicKeys.update { it - batch }
|
||||
println("Failed to fetch metadata batch: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestMetadata(pubkey: PublicKey) {
|
||||
if (seenPublicKeys.add(pubkey)) {
|
||||
metadataRequestChannel.trySend(pubkey)
|
||||
private suspend fun getCachedMetadata() {
|
||||
val cache = nostr.profiles.getAllCacheMetadata()
|
||||
cache.forEach { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||
profilesMutex.withLock {
|
||||
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||
private fun requestMetadata(pubkey: PublicKey) {
|
||||
var added = false
|
||||
seenPublicKeys.update { current ->
|
||||
if (current.contains(pubkey)) {
|
||||
added = false
|
||||
current
|
||||
} else {
|
||||
added = true
|
||||
current + pubkey
|
||||
}
|
||||
}
|
||||
if (added) metadataRequestChannel.trySend(pubkey)
|
||||
}
|
||||
|
||||
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||
profiles.update { current ->
|
||||
val flow = current[pubkey] ?: MutableStateFlow<Profile?>(null)
|
||||
flow.value = profile
|
||||
if (current.containsKey(pubkey)) current else current + (pubkey to flow)
|
||||
}
|
||||
seenPublicKeys.update { it + pubkey }
|
||||
}
|
||||
|
||||
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||
if (flow.value == null) requestMetadata(pubkey)
|
||||
return flow.asStateFlow()
|
||||
val currentMap = profiles.value
|
||||
val existingFlow = currentMap[pubkey]
|
||||
|
||||
if (existingFlow != null) {
|
||||
if (existingFlow.value == null) requestMetadata(pubkey)
|
||||
return existingFlow.asStateFlow()
|
||||
}
|
||||
|
||||
val newFlow = MutableStateFlow<Profile?>(null)
|
||||
var resultFlow = newFlow
|
||||
|
||||
profiles.update { prev ->
|
||||
if (prev.containsKey(pubkey)) {
|
||||
resultFlow = prev[pubkey]!!
|
||||
prev
|
||||
} else {
|
||||
prev + (pubkey to newFlow)
|
||||
}
|
||||
}
|
||||
|
||||
if (resultFlow.value == null) requestMetadata(pubkey)
|
||||
|
||||
return resultFlow.asStateFlow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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