Compare commits
1 Commits
v0.2.6
...
255c840847
| Author | SHA1 | Date | |
|---|---|---|---|
| 255c840847 |
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,7 +39,6 @@ import androidx.navigation3.ui.NavDisplay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.repository.AccountRepository
|
import su.reya.coop.repository.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.SettingsRepository
|
|
||||||
import su.reya.coop.screens.ContactListScreen
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
@@ -51,27 +50,17 @@ import su.reya.coop.screens.ProfileScreen
|
|||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
import su.reya.coop.screens.RequestListScreen
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.SettingsScreen
|
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
import su.reya.coop.screens.chat.ChatScreen
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.viewmodel.AccountViewModel
|
import su.reya.coop.viewmodel.AccountViewModel
|
||||||
import su.reya.coop.viewmodel.ChatScreenViewModel
|
import su.reya.coop.viewmodel.ChatScreenViewModel
|
||||||
import su.reya.coop.viewmodel.ChatViewModel
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import su.reya.coop.viewmodel.SettingsViewModel
|
|
||||||
|
|
||||||
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
|
||||||
error("No ProfileCache provided")
|
error("No ProfileCache provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalSettings = staticCompositionLocalOf<Settings> {
|
|
||||||
error("No Settings provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
@@ -90,8 +79,6 @@ fun App(
|
|||||||
profileCache: ProfileCache,
|
profileCache: ProfileCache,
|
||||||
accountRepository: AccountRepository,
|
accountRepository: AccountRepository,
|
||||||
chatRepository: ChatRepository,
|
chatRepository: ChatRepository,
|
||||||
settingsRepository: SettingsRepository,
|
|
||||||
connectivityMonitor: ConnectivityMonitor,
|
|
||||||
) {
|
) {
|
||||||
val viewModelFactory = remember {
|
val viewModelFactory = remember {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
@@ -105,10 +92,6 @@ fun App(
|
|||||||
accountRepository
|
accountRepository
|
||||||
)
|
)
|
||||||
|
|
||||||
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
|
|
||||||
settingsRepository
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
}
|
}
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
@@ -119,7 +102,6 @@ fun App(
|
|||||||
|
|
||||||
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
|
||||||
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
|
||||||
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
@@ -131,27 +113,19 @@ fun App(
|
|||||||
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
|
||||||
val signerRequired = accountState.signerRequired
|
val signerRequired = accountState.signerRequired
|
||||||
|
|
||||||
// Get the settings
|
|
||||||
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Get connectivity status
|
|
||||||
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Check if dark theme enabled
|
// Check if dark theme enabled
|
||||||
val darkMode = when (settings.theme) {
|
val darkMode = isSystemInDarkTheme()
|
||||||
Theme.Light -> false
|
|
||||||
Theme.Dark -> true
|
|
||||||
Theme.System -> isSystemInDarkTheme()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// Enable the dynamic color scheme for Android 12+
|
||||||
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||||
|
context
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// When dark mode is enabled, use the dark color scheme
|
// When dark mode is enabled, use the dark color scheme
|
||||||
darkMode -> darkColorScheme()
|
darkMode -> darkColorScheme()
|
||||||
@@ -216,12 +190,11 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalProfileCache provides profileCache,
|
LocalProfileCache provides profileCache,
|
||||||
LocalSettings provides settings,
|
|
||||||
LocalConnectivity provides isMobileData,
|
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
@@ -294,9 +267,6 @@ fun App(
|
|||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen(accountViewModel)
|
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.AccountRepository
|
||||||
import su.reya.coop.repository.ChatRepository
|
import su.reya.coop.repository.ChatRepository
|
||||||
import su.reya.coop.repository.MediaRepository
|
import su.reya.coop.repository.MediaRepository
|
||||||
import su.reya.coop.repository.SettingsRepository
|
|
||||||
import su.reya.coop.viewmodel.ProfileCache
|
import su.reya.coop.viewmodel.ProfileCache
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@@ -26,30 +25,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
|
||||||
private val scope = MainScope()
|
private val scope = MainScope()
|
||||||
|
|
||||||
private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) }
|
|
||||||
|
|
||||||
private val settingsRepository by lazy {
|
|
||||||
val storage = AppStore(this@MainActivity)
|
|
||||||
SettingsRepository(storage, scope)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val accountRepository by lazy {
|
private val accountRepository by lazy {
|
||||||
val storage = AppStore(this@MainActivity)
|
val storage = AppStore(this@MainActivity)
|
||||||
val mediaRepository = MediaRepository(settingsRepository)
|
val mediaRepository = MediaRepository()
|
||||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
AccountRepository(
|
AccountRepository(NostrManager.instance, storage, mediaRepository, scope, androidSigner)
|
||||||
NostrManager.instance,
|
|
||||||
storage,
|
|
||||||
mediaRepository,
|
|
||||||
settingsRepository,
|
|
||||||
scope,
|
|
||||||
androidSigner
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val chatRepository by lazy {
|
private val chatRepository by lazy {
|
||||||
val mediaRepository = MediaRepository(settingsRepository)
|
val mediaRepository = MediaRepository()
|
||||||
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
|
ChatRepository(NostrManager.instance, mediaRepository, scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -97,8 +82,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
profileCache = profileCache,
|
profileCache = profileCache,
|
||||||
accountRepository = accountRepository,
|
accountRepository = accountRepository,
|
||||||
chatRepository = chatRepository,
|
chatRepository = chatRepository,
|
||||||
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 = {}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -38,9 +38,6 @@ 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
|
||||||
@@ -59,27 +56,14 @@ data class MessageModel(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel {
|
||||||
val settings = LocalSettings.current
|
return remember(event, currentUser) {
|
||||||
val isMobileData = LocalConnectivity.current
|
|
||||||
|
|
||||||
return remember(event, currentUser, settings, isMobileData) {
|
|
||||||
val id = event.ensureId().id()!!
|
val id = event.ensureId().id()!!
|
||||||
val isMine = currentUser == event.author()
|
val isMine = currentUser == event.author()
|
||||||
val content = event.content()
|
val content = event.content()
|
||||||
val replyEventIds = event.tags().eventIds()
|
val replyEventIds = event.tags().eventIds()
|
||||||
|
|
||||||
val 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
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalProfileCache
|
import su.reya.coop.LocalProfileCache
|
||||||
import su.reya.coop.LocalSettings
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.RoomUiState
|
import su.reya.coop.RoomUiState
|
||||||
@@ -116,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()
|
||||||
|
|
||||||
@@ -267,7 +265,7 @@ 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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +349,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (requireScreening && settings.screening) {
|
when (requireScreening) {
|
||||||
true -> {
|
true -> {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ class AccountRepository(
|
|||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val storage: AppStorage,
|
private val storage: AppStorage,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
@@ -76,7 +75,9 @@ class AccountRepository(
|
|||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
|
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)
|
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ data class ChatState(
|
|||||||
class ChatRepository(
|
class ChatRepository(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val mediaRepository: MediaRepository,
|
private val mediaRepository: MediaRepository,
|
||||||
private val settingsRepository: SettingsRepository,
|
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||||
) : ErrorHost by createErrorHost() {
|
) : ErrorHost by createErrorHost() {
|
||||||
@@ -130,19 +129,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 +139,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)
|
||||||
@@ -254,18 +236,14 @@ 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) 0 else 1
|
|
||||||
)
|
|
||||||
rooms[newRoom.id] = newRoom
|
rooms[newRoom.id] = newRoom
|
||||||
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
|
||||||
// Only update preview if message is newer (handles sync/late arrivals)
|
// Only update preview if message is newer (handles sync/late arrivals)
|
||||||
rooms[roomId] = existingRoom.copy(
|
rooms[roomId] = existingRoom.copy(
|
||||||
lastMessage = event.content(),
|
lastMessage = event.content(),
|
||||||
createdAt = event.createdAt(),
|
createdAt = event.createdAt(),
|
||||||
kind = newKind,
|
kind = newKind
|
||||||
unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1
|
|
||||||
)
|
)
|
||||||
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
|
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
|
||||||
// Even if it's an older message, if it's from me, the room is ongoing
|
// Even if it's an older message, if it's from me, the room is ongoing
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
|
||||||
class MediaRepository(
|
class MediaRepository {
|
||||||
private val settingsRepository: SettingsRepository
|
|
||||||
) {
|
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(Json {
|
json(Json {
|
||||||
@@ -27,9 +25,12 @@ class MediaRepository(
|
|||||||
contentType: String? = "image/jpeg"
|
contentType: String? = "image/jpeg"
|
||||||
): String? {
|
): String? {
|
||||||
return try {
|
return try {
|
||||||
val url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
|
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||||
val blossom = BlossomClient(url, httpClient)
|
val descriptor = blossom.upload(
|
||||||
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
|
file = file,
|
||||||
|
contentType = contentType,
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
descriptor?.url
|
descriptor?.url
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
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.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++
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user