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
19 changed files with 533 additions and 54 deletions

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

@@ -51,6 +51,7 @@ 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
@@ -67,6 +68,10 @@ val LocalSettings = staticCompositionLocalOf<Settings> {
error("No Settings provided") 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")
} }
@@ -86,6 +91,7 @@ fun App(
accountRepository: AccountRepository, accountRepository: AccountRepository,
chatRepository: ChatRepository, chatRepository: ChatRepository,
settingsRepository: SettingsRepository, settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) { ) {
val viewModelFactory = remember { val viewModelFactory = remember {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
@@ -128,6 +134,9 @@ fun App(
// Get the settings // Get the settings
val settings by settingsViewModel.settings.collectAsStateWithLifecycle() 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() }
@@ -208,6 +217,7 @@ fun App(
CompositionLocalProvider( CompositionLocalProvider(
LocalProfileCache provides profileCache, LocalProfileCache provides profileCache,
LocalSettings provides settings, LocalSettings provides settings,
LocalConnectivity provides isMobileData,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator, LocalNavigator provides navigator,
LocalScanResult provides qrScanResult, LocalScanResult provides qrScanResult,
@@ -284,6 +294,9 @@ fun App(
entry<Screen.Relay> { entry<Screen.Relay> {
RelayScreen(accountViewModel) RelayScreen(accountViewModel)
} }
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
} }
) )
} }

View File

@@ -26,6 +26,8 @@ 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 { private val settingsRepository by lazy {
val storage = AppStore(this@MainActivity) val storage = AppStore(this@MainActivity)
SettingsRepository(storage, scope) SettingsRepository(storage, scope)
@@ -96,6 +98,7 @@ class MainActivity : ComponentActivity() {
accountRepository = accountRepository, accountRepository = accountRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
settingsRepository = settingsRepository, 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

@@ -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
@@ -655,11 +657,22 @@ 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 = {
BadgedBox(
badge = {
if (totalUnread > 0) {
Badge {
Text(totalUnread.toString())
}
}
}
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
@@ -679,18 +692,24 @@ 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 = {
BadgedBox(
badge = {
if (room.unreadCount > 0) {
Badge {
Text(room.unreadCount.toString())
}
}
}
) {
Avatar(picture = roomState.picture, description = roomState.picture) 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

@@ -95,15 +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 accountState by viewModel.state.collectAsStateWithLifecycle()
val loadedRelayList = accountState.userRelayList
val loadedMsgRelayList = accountState.userMsgRelayList
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,8 +26,17 @@ 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)
val showMedia = when (settings.media) {
MediaConfig.AlwaysEnabled -> true
MediaConfig.Disabled -> false
MediaConfig.DisabledForMobileData -> !isMobileData
}
if (showMedia) {
AsyncImage( AsyncImage(
model = picture, model = picture,
contentDescription = description, contentDescription = description,
@@ -35,4 +48,14 @@ fun Avatar(
error = placeholder, error = placeholder,
placeholder = 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

@@ -6,7 +6,7 @@ import kotlinx.serialization.Serializable
data class Settings( data class Settings(
val theme: Theme = Theme.System, val theme: Theme = Theme.System,
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
val media: Media = Media.AlwaysEnabled, val media: MediaConfig = MediaConfig.AlwaysEnabled,
val screening: Boolean = true, val screening: Boolean = true,
val blossomServer: String? = "https://blossom.band", val blossomServer: String? = "https://blossom.band",
) )
@@ -17,6 +17,6 @@ enum class Theme {
} }
@Serializable @Serializable
enum class Media { enum class MediaConfig {
Disabled, DisabledForMobileData, AlwaysEnabled 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

@@ -130,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 {
@@ -140,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)
@@ -237,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

@@ -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++