4 Commits

Author SHA1 Message Date
44c743d039 chore: bump version 2026-07-21 10:45:21 +07:00
a124dc4cc4 feat: add support for unread message and room highlighting (#49)
Reviewed-on: #49
2026-07-21 03:44:53 +00:00
4a8134dec0 chore: improve notification (#48)
Reviewed-on: #48
2026-07-20 07:02:00 +00:00
07085449a4 feat: add settings screen (#47)
Reviewed-on: #47
2026-07-20 03:30:26 +00:00
29 changed files with 713 additions and 129 deletions

1
.gitignore vendored
View File

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

View File

@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "0.2.5" versionName = "0.2.6"
} }
packaging { packaging {
resources { resources {

View File

@@ -7,6 +7,8 @@
android:required="false" /> android:required="false" />
<uses-permission android:name="android.permission.CAMERA" /> <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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -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)
}
}

View File

@@ -39,6 +39,7 @@ import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen import su.reya.coop.screens.ImportScreen
@@ -50,17 +51,27 @@ import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.SettingsScreen
import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.ProfileCache import su.reya.coop.viewmodel.ProfileCache
import su.reya.coop.viewmodel.SettingsViewModel
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> { val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided") error("No ProfileCache provided")
} }
val LocalSettings = staticCompositionLocalOf<Settings> {
error("No Settings provided")
}
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
false
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> { val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
@@ -79,6 +90,8 @@ fun App(
profileCache: ProfileCache, profileCache: ProfileCache,
accountRepository: AccountRepository, accountRepository: AccountRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) { ) {
val viewModelFactory = remember { val viewModelFactory = remember {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@@ -92,6 +105,10 @@ fun App(
accountRepository accountRepository
) )
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class") else -> throw IllegalArgumentException("Unknown ViewModel class")
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -102,6 +119,7 @@ fun App(
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory) val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory) val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
val context = LocalContext.current val context = LocalContext.current
val activity = context as? ComponentActivity val activity = context as? ComponentActivity
@@ -113,19 +131,27 @@ fun App(
val accountState by accountViewModel.state.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired val signerRequired = accountState.signerRequired
// Get the settings
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
// Get connectivity status
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled // Check if dark theme enabled
val darkMode = isSystemInDarkTheme() val darkMode = when (settings.theme) {
Theme.Light -> false
Theme.Dark -> true
Theme.System -> isSystemInDarkTheme()
}
// Enabled the dynamic color scheme // Enabled the dynamic color scheme
val colorScheme = when { val colorScheme = when {
// Enable the dynamic color scheme for Android 12+ // Enable the dynamic color scheme for Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme( if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
context
)
} }
// When dark mode is enabled, use the dark color scheme // When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
@@ -190,11 +216,12 @@ fun App(
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalProfileCache provides profileCache, LocalProfileCache provides profileCache,
LocalSettings provides settings,
LocalConnectivity provides isMobileData,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,
) { ) {
NavDisplay( NavDisplay(
backStack = backStack, backStack = backStack,
onBack = { onBack = {
@@ -267,6 +294,9 @@ fun App(
entry<Screen.Relay> { entry<Screen.Relay> {
RelayScreen(accountViewModel) RelayScreen(accountViewModel)
} }
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
} }
) )
} }

View File

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

View File

@@ -58,4 +58,7 @@ sealed interface Screen : NavKey {
@Serializable @Serializable
data object Relay : Screen data object Relay : Screen
@Serializable
data object Settings : Screen
} }

View File

@@ -11,10 +11,10 @@ import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -28,8 +28,6 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
class NostrForegroundService : Service() { class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val nostr by lazy { NostrManager.instance } private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null private var notificationJob: Job? = null
@@ -191,4 +189,13 @@ class NostrForegroundService : Service() {
super.onDestroy() super.onDestroy()
serviceScope.cancel() 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()
}
}
} }

View File

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

View File

@@ -25,6 +25,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -126,13 +128,13 @@ fun HomeScreen(
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle() val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by accountViewModel.isRelayListEmpty.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isRelayListEmpty = accountState.isRelayListEmpty
val isBannerDismissed = accountState.isNotificationBannerDismissed
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle() val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle() val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = accountState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
@@ -655,27 +657,39 @@ fun NewRequests(requests: List<Room>) {
else -> "" else -> ""
} }
val totalUnread = requests.sumOf { it.unreadCount }
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList) navigator.navigate(Screen.RequestList)
}, },
leadingContent = { leadingContent = {
Box( BadgedBox(
modifier = Modifier badge = {
.size(48.dp) if (totalUnread > 0) {
.clip(MaterialShapes.Clover4Leaf.toShape()), Badge {
contentAlignment = Alignment.Center Text(totalUnread.toString())
}
}
}
) { ) {
Surface( Box(
modifier = Modifier.size(48.dp), modifier = Modifier
color = MaterialTheme.colorScheme.tertiaryContainer, .size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
) { ) {
Box(contentAlignment = Alignment.Center) { Surface(
Icon( modifier = Modifier.size(48.dp),
painter = painterResource(Res.drawable.ic_request), color = MaterialTheme.colorScheme.tertiaryContainer,
contentDescription = "Requests", ) {
tint = MaterialTheme.colorScheme.onTertiaryFixed Box(contentAlignment = Alignment.Center) {
) Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
} }
} }
} }
@@ -683,14 +697,19 @@ fun NewRequests(requests: List<Room>) {
headlineContent = { headlineContent = {
Text( Text(
text = "Requests", text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
)
) )
}, },
supportingContent = { supportingContent = {
if (supportingText.isNotEmpty()) { if (supportingText.isNotEmpty()) {
Text( Text(
text = supportingText, 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, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
@@ -712,22 +731,35 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),
leadingContent = { 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 = { headlineContent = {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = roomState.name, 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) modifier = Modifier.weight(1f)
) )
Text( Text(
text = room.createdAt.ago(), text = room.createdAt.ago(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.End,
) )
} }
}, },
@@ -735,7 +767,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
if (!room.lastMessage.isNullOrBlank()) { if (!room.lastMessage.isNullOrBlank()) {
Text( Text(
text = room.lastMessage ?: "", 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, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
) )
@@ -760,7 +795,7 @@ fun BottomMenuList(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { navigator.navigate(Screen.ContactList) }, "Contact List" to { navigator.navigate(Screen.ContactList) },
"Relay Management" to { navigator.navigate(Screen.Relay) }, "Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { } "Settings" to { navigator.navigate(Screen.Settings) }
) )
Column( Column(

View File

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

View File

@@ -75,7 +75,8 @@ fun NewChatScreen(
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current val qrScanResult = LocalScanResult.current
val contactList by accountViewModel.contactList.collectAsStateWithLifecycle() val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val createGroup = remember { mutableStateOf(false) } val createGroup = remember { mutableStateOf(false) }

View File

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

View File

@@ -95,14 +95,15 @@ fun RelayScreen(viewModel: AccountViewModel) {
var openAddRelayDialog by remember { mutableStateOf(false) } var openAddRelayDialog by remember { mutableStateOf(false) }
var relayToDelete by remember { mutableStateOf<String?>(null) } var relayToDelete by remember { mutableStateOf<String?>(null) }
val accountState by viewModel.state.collectAsStateWithLifecycle()
val loadedRelayList = accountState.userRelayList
val loadedMsgRelayList = accountState.userMsgRelayList
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadCurrentUserRelayList() viewModel.loadCurrentUserRelayList()
viewModel.loadCurrentUserMsgRelayList() viewModel.loadCurrentUserMsgRelayList()
} }
val loadedRelayList by viewModel.userRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by viewModel.userMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) { LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) { if (loadedRelayList.isNotEmpty()) {
relayList.clear() relayList.clear()

View File

@@ -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 = {}
)
}

View File

@@ -38,6 +38,9 @@ import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent 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.URL_REGEX
import su.reya.coop.formatAsTime import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl import su.reya.coop.isImageUrl
@@ -56,14 +59,27 @@ data class MessageModel(
@Composable @Composable
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel { 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 id = event.ensureId().id()!!
val isMine = currentUser == event.author() val isMine = currentUser == event.author()
val content = event.content() val content = event.content()
val replyEventIds = event.tags().eventIds() val replyEventIds = event.tags().eventIds()
val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList() val showMedia = when (settings.media) {
val cleanedContent = content.removeImageUrls() 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 { val annotatedString = buildAnnotatedString {
var lastIndex = 0 var lastIndex = 0

View File

@@ -93,6 +93,7 @@ import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSettings
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomUiState import su.reya.coop.RoomUiState
@@ -115,6 +116,7 @@ fun ChatScreen(
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val settings = LocalSettings.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -265,7 +267,7 @@ fun ChatScreen(
.fillMaxSize() .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (requireScreening) { if (requireScreening && settings.screening) {
room?.let { ScreenerCard(accountViewModel, it) } room?.let { ScreenerCard(accountViewModel, it) }
} }
@@ -349,7 +351,7 @@ fun ChatScreen(
} }
} }
when (requireScreening) { when (requireScreening && settings.screening) {
true -> { true -> {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -1,5 +1,6 @@
package su.reya.coop.shared package su.reya.coop.shared
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.avatar import coop.composeapp.generated.resources.avatar
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalConnectivity
import su.reya.coop.LocalSettings
import su.reya.coop.MediaConfig
@Composable @Composable
fun Avatar( fun Avatar(
@@ -22,17 +26,36 @@ fun Avatar(
size: Dp = 48.dp, size: Dp = 48.dp,
shape: Shape = CircleShape shape: Shape = CircleShape
) { ) {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
val placeholder = painterResource(Res.drawable.avatar) val placeholder = painterResource(Res.drawable.avatar)
AsyncImage( val showMedia = when (settings.media) {
model = picture, MediaConfig.AlwaysEnabled -> true
contentDescription = description, MediaConfig.Disabled -> false
modifier = modifier MediaConfig.DisabledForMobileData -> !isMobileData
.size(size) }
.clip(shape),
contentScale = ContentScale.Crop, if (showMedia) {
fallback = placeholder, AsyncImage(
error = placeholder, model = picture,
placeholder = placeholder 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
)
}
} }

View File

@@ -0,0 +1,7 @@
package su.reya.coop
import kotlinx.coroutines.flow.StateFlow
interface ConnectivityMonitor {
val isMobileData: StateFlow<Boolean>
}

View File

@@ -30,7 +30,8 @@ data class Room(
val subject: String?, val subject: String?,
val members: Set<PublicKey>, val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default(), val kind: RoomKind = RoomKind.default(),
val lastMessage: String? = null val lastMessage: String? = null,
val unreadCount: Int = 0
) : Comparable<Room> { ) : Comparable<Room> {
override fun compareTo(other: Room): Int { override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs()) return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())

View File

@@ -0,0 +1,22 @@
package su.reya.coop
import kotlinx.serialization.Serializable
@Serializable
data class Settings(
val theme: Theme = Theme.System,
val dynamicColor: Boolean = true,
val media: 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
}

View File

@@ -154,7 +154,10 @@ class Nostr(
// Trigger new message notification // Trigger new message notification
if (rumor != null) { 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) onNewMessage(rumor)
} }
} }

View File

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

View File

@@ -36,6 +36,7 @@ data class ChatState(
class ChatRepository( class ChatRepository(
private val nostr: Nostr, private val nostr: Nostr,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
@@ -129,6 +130,19 @@ class ChatRepository(
return _state.value.rooms[id] 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() { fun refreshChatRooms() {
scope.launch(defaultDispatcher) { scope.launch(defaultDispatcher) {
try { try {
@@ -139,10 +153,14 @@ class ChatRepository(
val existing = newMap[dbRoom.id] val existing = newMap[dbRoom.id]
// Only update if the database version is newer or equal // Only update if the database version is newer or equal
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) { if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
// Preserve Ongoing kind if already marked as such in memory // Preserve Ongoing kind and unreadCount status if already marked as such in memory
val mergedKind = val mergedKind =
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind) val mergedUnreadCount = existing?.unreadCount ?: 0
newMap[dbRoom.id] = dbRoom.copy(
kind = mergedKind,
unreadCount = mergedUnreadCount
)
} }
} }
currentState.copy(rooms = newMap) currentState.copy(rooms = newMap)
@@ -236,14 +254,18 @@ class ChatRepository(
if (existingRoom == null) { if (existingRoom == null) {
// New room discovery // New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind) val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe) 0 else 1
)
rooms[newRoom.id] = newRoom rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals) // Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy( rooms[roomId] = existingRoom.copy(
lastMessage = event.content(), lastMessage = event.content(),
createdAt = event.createdAt(), createdAt = event.createdAt(),
kind = newKind kind = newKind,
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
) )
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) { } else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message, if it's from me, the room is ongoing // Even if it's an older message, if it's from me, the room is ongoing

View File

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

View File

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

View File

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

View File

@@ -42,6 +42,8 @@ class ChatScreenViewModel(
messages.clear() messages.clear()
messages.addAll(initialMessages.distinctBy { it.id() }) messages.addAll(initialMessages.distinctBy { it.id() })
loading = false 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 (event.roomId() == id) {
if (messages.none { it.id() == event.id() }) { if (messages.none { it.id() == event.id() }) {
messages.add(0, event) messages.add(0, event)
chatRepository.markAsRead(id)
} }
} else { } else {
newOtherMessages++ newOtherMessages++

View File

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