1 Commits
Author SHA1 Message Date
reya 255c840847 clean up 2026-07-18 16:11:34 +07:00
25 changed files with 168 additions and 1078 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.2.6"
versionName = "0.2.5"
}
packaging {
resources {
@@ -7,8 +7,6 @@
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" />
@@ -1,49 +0,0 @@
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)
}
}
@@ -39,7 +39,6 @@ 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,27 +50,17 @@ 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")
}
@@ -90,8 +79,6 @@ fun App(
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) {
val viewModelFactory = remember {
object : ViewModelProvider.Factory {
@@ -105,10 +92,6 @@ fun App(
accountRepository
)
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
@@ -119,7 +102,6 @@ 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
@@ -131,27 +113,19 @@ 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 = when (settings.theme) {
Theme.Light -> false
Theme.Dark -> true
Theme.System -> isSystemInDarkTheme()
}
val darkMode = isSystemInDarkTheme()
// Enabled the dynamic color scheme
val colorScheme = when {
// Enable the dynamic color scheme for Android 12+
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
}
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme()
@@ -216,12 +190,11 @@ 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 = {
@@ -294,9 +267,6 @@ fun App(
entry<Screen.Relay> {
RelayScreen(accountViewModel)
}
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
}
)
}
@@ -14,7 +14,6 @@ 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
@@ -26,30 +25,16 @@ 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(settingsRepository)
val mediaRepository = MediaRepository()
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
AccountRepository(
NostrManager.instance,
storage,
mediaRepository,
settingsRepository,
scope,
androidSigner
)
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
}
private val chatRepository by lazy {
val mediaRepository = MediaRepository(settingsRepository)
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
val mediaRepository = MediaRepository()
ChatRepository(NostrManager.instance, mediaRepository, scope)
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -97,8 +82,6 @@ class MainActivity : ComponentActivity() {
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
settingsRepository = settingsRepository,
connectivityMonitor = connectivityMonitor,
)
}
}
@@ -58,7 +58,4 @@ 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,6 +28,8 @@ 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
@@ -189,13 +191,4 @@ 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()
}
}
}
@@ -25,8 +25,6 @@ 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
@@ -657,39 +655,27 @@ fun NewRequests(requests: List<Room>) {
else -> ""
}
val totalUnread = requests.sumOf { it.unreadCount }
ListItem(
modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList)
},
leadingContent = {
BadgedBox(
badge = {
if (totalUnread > 0) {
Badge {
Text(totalUnread.toString())
}
}
}
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
Surface(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
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
)
}
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
}
}
@@ -697,19 +683,14 @@ fun NewRequests(requests: List<Room>) {
headlineContent = {
Text(
text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
)
style = MaterialTheme.typography.titleMediumEmphasized
)
},
supportingContent = {
if (supportingText.isNotEmpty()) {
Text(
text = supportingText,
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
),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -731,35 +712,22 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
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 = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
),
style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.weight(1f)
)
Text(
text = room.createdAt.ago(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.End,
color = MaterialTheme.colorScheme.outline
)
}
},
@@ -767,10 +735,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
if (!room.lastMessage.isNullOrBlank()) {
Text(
text = room.lastMessage ?: "",
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
),
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
@@ -795,7 +760,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 { navigator.navigate(Screen.Settings) }
"Settings" to { }
)
Column(
@@ -95,15 +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 accountState by viewModel.state.collectAsStateWithLifecycle()
val loadedRelayList = accountState.userRelayList
val loadedMsgRelayList = accountState.userMsgRelayList
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
relayList.clear()
@@ -1,291 +0,0 @@
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 = {}
)
}
@@ -5,13 +5,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -38,26 +34,15 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable
data class MessageModel(
val id: EventId,
@@ -66,37 +51,19 @@ data class MessageModel(
val images: List<String>,
val timestamp: String,
val isMine: Boolean,
val replyEventIds: List<EventId>,
val reactions: List<ReactionGroup> = emptyList()
val replyEventIds: List<EventId>
)
@Composable
fun rememberMessageModel(
event: UnsignedEvent,
reactions: List<UnsignedEvent> = emptyList(),
currentUser: PublicKey? = null
): MessageModel {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
return remember(event, reactions, currentUser, settings, isMobileData) {
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
return remember(event, currentUser) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
val replyEventIds = event.tags().eventIds()
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 images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
val cleanedContent = content.removeImageUrls()
val annotatedString = buildAnnotatedString {
var lastIndex = 0
@@ -121,16 +88,6 @@ fun rememberMessageModel(
append(cleanedContent.substring(lastIndex))
}
val groupedReactions = reactions.groupBy { it.content() }
.map { (emoji, events) ->
val authors = events.map { it.author() }
ReactionGroup(
emoji = emoji,
authors = authors,
containsMe = authors.any { it == currentUser }
)
}
MessageModel(
id = id,
author = event.author(),
@@ -138,8 +95,7 @@ fun rememberMessageModel(
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = isMine,
replyEventIds = replyEventIds,
reactions = groupedReactions
replyEventIds = replyEventIds
)
}
}
@@ -186,108 +142,46 @@ fun ChatMessage(
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
Column(
modifier = Modifier.padding(
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
if (model.annotatedContent.isNotBlank()) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
) {
if (model.annotatedContent.isNotBlank()) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
) {
Text(
text = model.annotatedContent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
model.images.forEach { imageUrl ->
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 280.dp)
) {
AsyncImage(
model = imageUrl,
contentDescription = "Image from chat",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.FillWidth
)
}
}
Text(
text = model.annotatedContent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyLarge
)
}
if (model.reactions.isNotEmpty()) {
MessageReactions(
reactions = model.reactions,
modifier = Modifier.offset(y = 12.dp)
}
model.images.forEach { imageUrl ->
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 280.dp)
) {
AsyncImage(
model = imageUrl,
contentDescription = "Image from chat",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.FillWidth
)
}
}
if (isMessageClicked) {
Text(
text = model.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
modifier = Modifier.align(
if (model.isMine) Alignment.End else Alignment.Start
)
)
}
}
}
}
@Composable
private fun MessageReactions(
reactions: List<ReactionGroup>,
modifier: Modifier = Modifier
) {
val totalCount = reactions.sumOf { it.authors.size }
val displayEmojis = reactions.take(3).map { it.emoji }
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
displayEmojis.forEach { emoji ->
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = emoji,
fontSize = 12.sp,
)
}
}
}
if (totalCount > 2) {
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = totalCount.toString(),
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
)
}
}
}
}
}
@@ -80,7 +80,6 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
@@ -91,12 +90,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
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
@@ -119,7 +115,6 @@ 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,21 +141,10 @@ fun ChatScreen(
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val allEvents = viewModel.messages
val displayMessages by remember {
derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } }
}
val reactionsByMessage by remember {
derivedStateOf {
allEvents.filter { it.kind().asStd() == KindStandard.REACTION }
.groupBy { it.tags().eventIds().firstOrNull() }
}
}
val messages = viewModel.messages
val groupedMessages =
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
@@ -175,29 +159,6 @@ fun ChatScreen(
label = "blurAnimation"
)
val goToMessage = { eventId: EventId? ->
if (eventId != null) {
scope.launch {
var targetIndex = -1
var currentIndex = 0
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
}
currentIndex += msgInGroup.size + 1
}
if (targetIndex != -1) {
listState.animateScrollToItem(targetIndex)
}
}
}
}
val sendFile = { uri: Uri ->
scope.launch {
// Read file on IO dispatcher
@@ -227,13 +188,15 @@ fun ChatScreen(
}
}
LaunchedEffect(allEvents.size) {
if (displayMessages.isNotEmpty()) {
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxSize()
) {
Scaffold(
modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
@@ -302,11 +265,11 @@ fun ChatScreen(
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (requireScreening && settings.screening) {
if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) }
}
when (displayMessages.isNotEmpty()) {
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
@@ -321,15 +284,14 @@ fun ChatScreen(
items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model =
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
rememberMessageModel(event, currentUser?.publicKey)
val replyPreview =
remember(model.replyEventIds, displayMessages.size) {
remember(model.replyEventIds, messages.size) {
model.replyEventIds.firstOrNull()
?.let { replyId ->
displayMessages.find { it.ensureId().id() == replyId }
messages.find { it.id() == replyId }
}
}
@@ -339,13 +301,7 @@ fun ChatScreen(
.animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
replyPreview?.let { previewEvent ->
ReplyPreview(
event = previewEvent,
isMine = model.isMine,
onClick = { goToMessage(previewEvent.id()) }
)
}
replyPreview?.let { ReplyPreview(it, model.isMine) }
ChatMessage(
model = model,
modifier = Modifier.graphicsLayer {
@@ -393,7 +349,7 @@ fun ChatScreen(
}
}
when (requireScreening && settings.screening) {
when (requireScreening) {
true -> {
Row(
modifier = Modifier
@@ -436,9 +392,8 @@ fun ChatScreen(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(text, replyingTo?.id)
viewModel.sendMessage(text)
text = ""
replyingTo = null
},
onUpload = {
fileLauncher.launch("image/*")
@@ -478,11 +433,14 @@ fun ChatScreen(
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) }
var toolbarHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 6.dp.toPx() }
val spacing = with(density) { 12.dp.toPx() }
val showAbove =
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
Box(
modifier = Modifier
@@ -491,30 +449,11 @@ fun ChatScreen(
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
// Reaction Toolbar (Above)
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) }
.onGloballyPositioned { toolbarHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ReactionToolbar(
onReaction = { reaction ->
viewModel.sendReaction(model.id, reaction)
selectedMessage = null
}
)
}
// Message Preview
ChatMessage(
model = model,
modifier = Modifier
@@ -522,35 +461,38 @@ fun ChatScreen(
.padding(horizontal = 16.dp)
)
// Action Menu (Below)
val menuOffset = if (showAbove) {
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
.offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu(
onAction = { action ->
when (action) {
"Copy" -> {
scope.launch {
val content = model.annotatedContent
val data = ClipData.newPlainText(content, content)
clipboardManager.setClipEntry(ClipEntry(data))
}
ContextMenu { action ->
when (action) {
"Copy" -> {
scope.launch {
val content = model.annotatedContent
val data = ClipData.newPlainText(content, content)
clipboardManager.setClipEntry(ClipEntry(data))
}
"Reply" -> {
replyingTo = model
}
else -> {}
}
selectedMessage = null
"Reply" -> {
replyingTo = model
}
else -> {}
}
)
selectedMessage = null
}
}
}
}
@@ -600,11 +542,7 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
}
@Composable
private fun ReplyPreview(
event: UnsignedEvent,
isMine: Boolean = false,
onClick: () -> Unit
) {
private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle()
@@ -620,9 +558,7 @@ private fun ReplyPreview(
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
modifier = Modifier
.widthIn(max = 280.dp)
.clickable(onClick = onClick),
modifier = Modifier.widthIn(max = 280.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape,
) {
@@ -630,13 +566,13 @@ private fun ReplyPreview(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = event.content(),
@@ -649,41 +585,9 @@ private fun ReplyPreview(
}
}
@Composable
private fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
shadowElevation = 1.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
reactionEmojis.forEach { emoji ->
Text(
text = emoji,
modifier = Modifier
.clickable { onReaction(emoji) }
.padding(4.dp),
fontSize = 24.sp
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(
onAction: (String) -> Unit
) {
private fun ContextMenu(onAction: (String) -> Unit) {
val menuItems = listOf(
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
@@ -692,8 +596,6 @@ private fun ContextMenu(
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = Modifier.width(220.dp)
) {
val itemCount = menuItems.size
@@ -1,6 +1,5 @@
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
@@ -14,9 +13,6 @@ 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(
@@ -26,36 +22,17 @@ fun Avatar(
size: Dp = 48.dp,
shape: Shape = CircleShape
) {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
val placeholder = painterResource(Res.drawable.avatar)
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
)
}
AsyncImage(
model = picture,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop,
fallback = placeholder,
error = placeholder,
placeholder = placeholder
)
}
@@ -1,7 +0,0 @@
package su.reya.coop
import kotlinx.coroutines.flow.StateFlow
interface ConnectivityMonitor {
val isMobileData: StateFlow<Boolean>
}
@@ -30,8 +30,7 @@ data class Room(
val subject: String?,
val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default(),
val lastMessage: String? = null,
val unreadCount: Int = 0
val lastMessage: String? = null
) : Comparable<Room> {
override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
@@ -1,22 +0,0 @@
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
}
@@ -144,15 +144,12 @@ class MessageManager(private val nostr: Nostr) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14", kValue))
Tag.custom("k", listOf("14"))
)
// Set event kind
@@ -179,13 +176,12 @@ class MessageManager(private val nostr: Nostr) {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all rumors (DMs and Reactions)
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
// Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
events
.map { UnsignedEvent.fromJson(it.content()) }
@@ -193,41 +189,18 @@ class MessageManager(private val nostr: Nostr) {
.forEach { rumor ->
val id = rumor.roomId()
val isFromMe = rumor.author() == userPubkey
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val existing = roomsMap[id]
val createdAt = rumor.createdAt()
if (existing == null) {
// If the room is new or the current rumor is newer than the existing one
if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) {
// A room is "Ongoing" if it was already marked as such or if the current rumor is from the user
val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
// If the first event we see is a reaction, don't use it as lastMessage
roomsMap[id] = if (isReaction) {
room.copy(lastMessage = null)
} else {
lastDmTimestampMap[id] = createdAt.asSecs()
room
}
if (isFromMe) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
} else {
// Update the overall room timestamp (for sorting) if this event is newer
if (createdAt.asSecs() > existing.createdAt.asSecs()) {
roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt)
}
// Update the last message content if this is a DM and it's newer than the last DM we've seen
if (!isReaction) {
val lastDmTs = lastDmTimestampMap[id] ?: 0uL
if (createdAt.asSecs() >= lastDmTs) {
lastDmTimestampMap[id] = createdAt.asSecs()
roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content())
}
}
// If any event is from the user, mark the room as Ongoing
if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
} else if (isFromMe && existing.kind != RoomKind.Ongoing) {
// If it's an older rumor but sent by the user, mark the room as Ongoing
roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
}
}
@@ -375,64 +348,4 @@ class MessageManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun sendReaction(
to: Set<PublicKey>,
targetEventId: EventId,
reaction: String,
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
tags.add(Tag.event(targetEventId))
// Add public key tags for each recipient (including me) to ensure roomId consistency
to.forEach { pubkey ->
tags.add(Tag.publicKey(pubkey))
}
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.REACTION), reaction)
.tags(tags)
.finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom("k", listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to send reaction: ${e.message}", e)
}
}
}
@@ -154,10 +154,7 @@ class Nostr(
// Trigger new message notification
if (rumor != null) {
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
if (isNew && !isSelfMessage) {
if (rumor.createdAt().asSecs() >= now.asSecs()) {
onNewMessage(rumor)
}
}
@@ -22,15 +22,13 @@ import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) {
companion object {
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
"wss://user.kindpag.es",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
@@ -57,7 +57,6 @@ 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,
@@ -76,7 +75,9 @@ class AccountRepository(
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
.flatMapLatest { pubkey ->
if (pubkey != null) currentUserProfileFlow(pubkey) else flowOf(null)
}
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
init {
@@ -36,7 +36,6 @@ 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() {
@@ -130,19 +129,6 @@ 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 {
@@ -153,14 +139,10 @@ class ChatRepository(
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
// Preserve Ongoing kind 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
)
newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind)
}
}
currentState.copy(rooms = newMap)
@@ -241,29 +223,8 @@ class ChatRepository(
}
}
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendReaction(
to = room.members,
targetEventId = targetEventId,
reaction = reaction,
onRumorCreated = {
scope.launch(defaultDispatcher) {
updateRoomState(it, roomId)
}
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
@@ -275,26 +236,20 @@ class ChatRepository(
if (existingRoom == null) {
// New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe || isReaction) 0 else 1,
lastMessage = if (isReaction) null else event.content()
)
val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Update timestamp for any newer event (DM or Reaction)
// But only update preview if it's a DM
// Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy(
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
lastMessage = event.content(),
createdAt = event.createdAt(),
kind = newKind,
unreadCount = if (isFromMe || isReaction) existingRoom.unreadCount else existingRoom.unreadCount + 1
kind = newKind
)
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message or reaction, 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
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else {
// Don't update the room list state for older messages or reactions that don't change preview
// Don't update the room list state for older messages
return@update currentState
}
currentState.copy(rooms = rooms)
@@ -8,9 +8,7 @@ import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
class MediaRepository(
private val settingsRepository: SettingsRepository
) {
class MediaRepository {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
@@ -27,9 +25,12 @@ class MediaRepository(
contentType: String? = "image/jpeg"
): String? {
return try {
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
val blossom = BlossomClient(url, httpClient)
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
descriptor?.url
} catch (e: CancellationException) {
throw e
@@ -1,61 +0,0 @@
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()
}
}
}
@@ -42,8 +42,6 @@ class ChatScreenViewModel(
messages.clear()
messages.addAll(initialMessages.distinctBy { it.id() })
loading = false
// Mark the room as read once messages are loaded
chatRepository.markAsRead(id)
}
}
@@ -57,7 +55,6 @@ class ChatScreenViewModel(
if (event.roomId() == id) {
if (messages.none { it.id() == event.id() }) {
messages.add(0, event)
chatRepository.markAsRead(id)
}
} else {
newOtherMessages++
@@ -74,8 +71,4 @@ class ChatScreenViewModel(
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
}
@@ -1,16 +0,0 @@
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)
}
}