3 Commits
Author SHA1 Message Date
reya 6bb4ef2805 add settings viewmodel 2026-07-20 08:52:38 +07:00
reya e4e73da229 update settings 2026-07-20 08:32:34 +07:00
reya 255c840847 clean up 2026-07-18 16:11:34 +07:00
21 changed files with 150 additions and 931 deletions
+1 -1
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.6" versionName = "0.2.5"
} }
packaging { packaging {
resources { resources {
@@ -7,8 +7,6 @@
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" />
@@ -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)
}
}
@@ -51,7 +51,6 @@ 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
@@ -68,10 +67,6 @@ 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")
} }
@@ -91,7 +86,6 @@ 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 {
@@ -134,9 +128,6 @@ 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() }
@@ -217,7 +208,6 @@ 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,
@@ -294,9 +284,6 @@ fun App(
entry<Screen.Relay> { entry<Screen.Relay> {
RelayScreen(accountViewModel) RelayScreen(accountViewModel)
} }
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
} }
) )
} }
@@ -26,8 +26,6 @@ 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)
@@ -98,7 +96,6 @@ class MainActivity : ComponentActivity() {
accountRepository = accountRepository, accountRepository = accountRepository,
chatRepository = chatRepository, chatRepository = chatRepository,
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
connectivityMonitor = connectivityMonitor,
) )
} }
} }
@@ -58,7 +58,4 @@ sealed interface Screen : NavKey {
@Serializable @Serializable
data object Relay : Screen data object Relay : Screen
@Serializable
data object Settings : Screen
} }
@@ -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,6 +28,8 @@ 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
@@ -189,13 +191,4 @@ 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()
}
}
} }
@@ -25,8 +25,6 @@ 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
@@ -657,22 +655,11 @@ 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)
@@ -692,24 +679,18 @@ fun NewRequests(requests: List<Room>) {
} }
} }
} }
}
}, },
headlineContent = { headlineContent = {
Text( Text(
text = "Requests", text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized.copy( style = MaterialTheme.typography.titleMediumEmphasized
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.copy( style = MaterialTheme.typography.bodyMedium,
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
) )
@@ -731,35 +712,22 @@ 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.copy( style = MaterialTheme.typography.titleMediumEmphasized,
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,
) )
} }
}, },
@@ -767,10 +735,7 @@ 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.copy( style = MaterialTheme.typography.bodyMedium,
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,
) )
@@ -795,7 +760,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 { navigator.navigate(Screen.Settings) } "Settings" to { }
) )
Column( Column(
@@ -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()
@@ -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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface 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.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage 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
import su.reya.coop.removeImageUrls import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable @Immutable
data class MessageModel( data class MessageModel(
val id: EventId, val id: EventId,
@@ -66,37 +51,19 @@ data class MessageModel(
val images: List<String>, val images: List<String>,
val timestamp: String, val timestamp: String,
val isMine: Boolean, val isMine: Boolean,
val replyEventIds: List<EventId>, val replyEventIds: List<EventId>
val reactions: List<ReactionGroup> = emptyList()
) )
@Composable @Composable
fun rememberMessageModel( fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
event: UnsignedEvent, return remember(event, currentUser) {
reactions: List<UnsignedEvent> = emptyList(),
currentUser: PublicKey? = null
): MessageModel {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
return remember(event, reactions, 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 showMedia = when (settings.media) { val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
MediaConfig.AlwaysEnabled -> true val cleanedContent = content.removeImageUrls()
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
@@ -121,16 +88,6 @@ fun rememberMessageModel(
append(cleanedContent.substring(lastIndex)) 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( MessageModel(
id = id, id = id,
author = event.author(), author = event.author(),
@@ -138,8 +95,7 @@ fun rememberMessageModel(
images = images, images = images,
timestamp = event.createdAt().formatAsTime(), timestamp = event.createdAt().formatAsTime(),
isMine = isMine, isMine = isMine,
replyEventIds = replyEventIds, replyEventIds = replyEventIds
reactions = groupedReactions
) )
} }
} }
@@ -185,15 +141,6 @@ fun ChatMessage(
), ),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp) 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()) { if (model.annotatedContent.isNotBlank()) {
Surface( Surface(
@@ -225,69 +172,16 @@ fun ChatMessage(
) )
} }
} }
}
if (model.reactions.isNotEmpty()) {
MessageReactions(
reactions = model.reactions,
modifier = Modifier.offset(y = 12.dp)
)
}
}
if (isMessageClicked) { if (isMessageClicked) {
Text( Text(
text = model.timestamp, text = model.timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, 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.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
@@ -91,12 +90,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
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
@@ -119,7 +115,6 @@ 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()
@@ -146,21 +141,10 @@ fun ChatScreen(
val loading = viewModel.loading val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening val requireScreening = viewModel.requireScreening
val allEvents = viewModel.messages val messages = 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 groupedMessages = val groupedMessages =
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } } remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) { val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey) (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
@@ -175,29 +159,6 @@ fun ChatScreen(
label = "blurAnimation" 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 -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
// Read file on IO dispatcher // Read file on IO dispatcher
@@ -227,13 +188,15 @@ fun ChatScreen(
} }
} }
LaunchedEffect(allEvents.size) { LaunchedEffect(messages.size) {
if (displayMessages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
} }
} }
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier.fillMaxSize()
) {
Scaffold( Scaffold(
modifier = Modifier.blur(blurAmount), modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
@@ -302,11 +265,11 @@ fun ChatScreen(
.fillMaxSize() .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (requireScreening && settings.screening) { if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) } room?.let { ScreenerCard(accountViewModel, it) }
} }
when (displayMessages.isNotEmpty()) { when (messages.isNotEmpty()) {
true -> { true -> {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -321,15 +284,14 @@ fun ChatScreen(
items = messagesInGroup, items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! } key = { it.ensureId().id()?.toHex()!! }
) { event -> ) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model = val model =
rememberMessageModel(event, msgReactions, currentUser?.publicKey) rememberMessageModel(event, currentUser?.publicKey)
val replyPreview = val replyPreview =
remember(model.replyEventIds, displayMessages.size) { remember(model.replyEventIds, messages.size) {
model.replyEventIds.firstOrNull() model.replyEventIds.firstOrNull()
?.let { replyId -> ?.let { replyId ->
displayMessages.find { it.ensureId().id() == replyId } messages.find { it.id() == replyId }
} }
} }
@@ -339,13 +301,7 @@ fun ChatScreen(
.animateItem(), .animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { previewEvent -> replyPreview?.let { ReplyPreview(it, model.isMine) }
ReplyPreview(
event = previewEvent,
isMine = model.isMine,
onClick = { goToMessage(previewEvent.id()) }
)
}
ChatMessage( ChatMessage(
model = model, model = model,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {
@@ -393,7 +349,7 @@ fun ChatScreen(
} }
} }
when (requireScreening && settings.screening) { when (requireScreening) {
true -> { true -> {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -436,9 +392,8 @@ fun ChatScreen(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
onSend = { onSend = {
viewModel.sendMessage(text, replyingTo?.id) viewModel.sendMessage(text)
text = "" text = ""
replyingTo = null
}, },
onUpload = { onUpload = {
fileLauncher.launch("image/*") fileLauncher.launch("image/*")
@@ -478,11 +433,14 @@ fun ChatScreen(
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current val density = LocalDensity.current
val windowInfo = LocalWindowInfo.current
val windowHeight = windowInfo.containerSize.height
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) } var menuHeight by remember { mutableFloatStateOf(0f) }
var toolbarHeight by remember { mutableFloatStateOf(0f) } val spacing = with(density) { 12.dp.toPx() }
val spacing = with(density) { 6.dp.toPx() } val showAbove =
(windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing)
Box( Box(
modifier = Modifier modifier = Modifier
@@ -491,30 +449,11 @@ fun ChatScreen(
.clickable { selectedMessage = null } .clickable { selectedMessage = null }
.verticalScroll(scrollState), .verticalScroll(scrollState),
) { ) {
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) + val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() } val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp)) 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( ChatMessage(
model = model, model = model,
modifier = Modifier modifier = Modifier
@@ -522,17 +461,21 @@ fun ChatScreen(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) )
// Action Menu (Below) val menuOffset = if (showAbove) {
bounds.top - menuHeight - spacing
} else {
bounds.bottom + spacing
}
Box( Box(
modifier = Modifier modifier = Modifier
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) } .offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() } .onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
ContextMenu( ContextMenu { action ->
onAction = { action ->
when (action) { when (action) {
"Copy" -> { "Copy" -> {
scope.launch { scope.launch {
@@ -550,7 +493,6 @@ fun ChatScreen(
} }
selectedMessage = null selectedMessage = null
} }
)
} }
} }
} }
@@ -600,11 +542,7 @@ private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
} }
@Composable @Composable
private fun ReplyPreview( private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) {
event: UnsignedEvent,
isMine: Boolean = false,
onClick: () -> Unit
) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) } val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle() val profile by profileFlow.collectAsStateWithLifecycle()
@@ -620,9 +558,7 @@ private fun ReplyPreview(
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Surface( Surface(
modifier = Modifier modifier = Modifier.widthIn(max = 280.dp),
.widthIn(max = 280.dp)
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.tertiaryContainer, color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape, shape = bubbleShape,
) { ) {
@@ -630,13 +566,13 @@ private fun ReplyPreview(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
Text( Text(
text = profile?.name ?: "Unknown", text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
color = MaterialTheme.colorScheme.onTertiaryContainer, alpha = 0.6f
),
) )
Text( Text(
text = event.content(), 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) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun ContextMenu( private fun ContextMenu(onAction: (String) -> Unit) {
onAction: (String) -> Unit
) {
val menuItems = listOf( val menuItems = listOf(
"Copy" to Res.drawable.ic_copy, "Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply "Reply" to Res.drawable.ic_reply
@@ -692,8 +596,6 @@ private fun ContextMenu(
DropdownMenuGroup( DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1), shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor, containerColor = MenuDefaults.groupVibrantContainerColor,
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = Modifier.width(220.dp) modifier = Modifier.width(220.dp)
) { ) {
val itemCount = menuItems.size val itemCount = menuItems.size
@@ -1,6 +1,5 @@
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
@@ -14,9 +13,6 @@ 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(
@@ -26,17 +22,8 @@ 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,
@@ -48,14 +35,4 @@ 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
)
}
} }
@@ -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 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())
@@ -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: MediaConfig = MediaConfig.AlwaysEnabled, val media: Media = Media.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 MediaConfig { enum class Media {
Disabled, DisabledForMobileData, AlwaysEnabled Disabled, DisabledForMobileData, AlwaysEnabled
} }
@@ -144,15 +144,12 @@ class MessageManager(private val nostr: Nostr) {
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try { try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// Construct reference tags // Construct reference tags
val tags = listOf( val tags = listOf(
Tag.identifier(giftId.toHex()), Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()), Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())), Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14", kValue)) Tag.custom("k", listOf("14"))
) )
// Set event kind // Set event kind
@@ -179,13 +176,12 @@ class MessageManager(private val nostr: Nostr) {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K) val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all rumors (DMs and Reactions) // Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction")) val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter)?.toVec() ?: return null val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms // Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf() val roomsMap: MutableMap<Long, Room> = mutableMapOf()
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
events events
.map { UnsignedEvent.fromJson(it.content()) } .map { UnsignedEvent.fromJson(it.content()) }
@@ -193,41 +189,18 @@ class MessageManager(private val nostr: Nostr) {
.forEach { rumor -> .forEach { rumor ->
val id = rumor.roomId() val id = rumor.roomId()
val isFromMe = rumor.author() == userPubkey val isFromMe = rumor.author() == userPubkey
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val existing = roomsMap[id] val existing = roomsMap[id]
val createdAt = rumor.createdAt() 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) 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 (isOngoing) room.copy(kind = RoomKind.Ongoing) else room
roomsMap[id] = if (isReaction) { } else if (isFromMe && existing.kind != RoomKind.Ongoing) {
room.copy(lastMessage = null) // If it's an older rumor but sent by the user, mark the room as Ongoing
} else { roomsMap[id] = existing.copy(kind = RoomKind.Ongoing)
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)
}
} }
} }
@@ -375,64 +348,4 @@ class MessageManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to send message: ${e.message}", e) 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 // Trigger new message notification
if (rumor != null) { if (rumor != null) {
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value if (rumor.createdAt().asSecs() >= now.asSecs()) {
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
if (isNew && !isSelfMessage) {
onNewMessage(rumor) onNewMessage(rumor)
} }
} }
@@ -22,15 +22,13 @@ import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) { class RelayManager(private val nostr: Nostr) {
companion object { companion object {
val BOOTSTRAP_RELAYS = listOf( val BOOTSTRAP_RELAYS = listOf(
"wss://relay.ditto.pub",
"wss://relay.primal.net", "wss://relay.primal.net",
"wss://relay.nostr.net", "wss://relay.ditto.pub",
"wss://profiles.nostr1.com", "wss://user.kindpag.es",
) )
val INDEXER_RELAY = listOf( val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social", "wss://indexer.coracle.social",
"wss://user.kindpag.es",
) )
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
@@ -130,19 +130,6 @@ 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 {
@@ -153,14 +140,10 @@ 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 and unreadCount status if already marked as such in memory // Preserve Ongoing kind 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
val mergedUnreadCount = existing?.unreadCount ?: 0 newMap[dbRoom.id] = dbRoom.copy(kind = mergedKind)
newMap[dbRoom.id] = dbRoom.copy(
kind = mergedKind,
unreadCount = mergedUnreadCount
)
} }
} }
currentState.copy(rooms = newMap) currentState.copy(rooms = newMap)
@@ -241,29 +224,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()) { private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState -> _state.update { currentState ->
val rooms = currentState.rooms.toMutableMap() val rooms = currentState.rooms.toMutableMap()
@@ -275,26 +237,20 @@ class ChatRepository(
if (existingRoom == null) { if (existingRoom == null) {
// New room discovery // New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy( val newRoom = Room.new(event, currentUser, roomId).copy(kind = newKind)
kind = newKind,
unreadCount = if (isFromMe || isReaction) 0 else 1,
lastMessage = if (isReaction) null else event.content()
)
rooms[newRoom.id] = newRoom rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Update timestamp for any newer event (DM or Reaction) // Only update preview if message is newer (handles sync/late arrivals)
// But only update preview if it's a DM
rooms[roomId] = existingRoom.copy( rooms[roomId] = existingRoom.copy(
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(), lastMessage = event.content(),
createdAt = event.createdAt(), createdAt = event.createdAt(),
kind = newKind, kind = newKind
unreadCount = if (isFromMe || isReaction) 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 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) rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else { } 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 return@update currentState
} }
currentState.copy(rooms = rooms) currentState.copy(rooms = rooms)
@@ -42,8 +42,6 @@ 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)
} }
} }
@@ -57,7 +55,6 @@ 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++
@@ -74,8 +71,4 @@ class ChatScreenViewModel(
fun sendFileMessage(file: ByteArray?, type: String?) { fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type) chatRepository.sendFileMessage(id, file, type)
} }
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
} }