This commit is contained in:
2026-07-10 20:31:15 +07:00
parent 645430875c
commit c5b76e9b2f
19 changed files with 430 additions and 301 deletions

View File

@@ -51,6 +51,7 @@ import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
@@ -64,6 +65,10 @@ val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided")
}
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
error("No RelayViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@@ -80,6 +85,7 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@Composable
fun App(
nostrViewModel: NostrViewModel,
relayViewModel: RelayViewModel,
chatViewModel: ChatViewModel,
authViewModel: AuthViewModel,
) {
@@ -133,6 +139,11 @@ fun App(
snackbarHostState.showSnackbar(message)
}
}
launch {
relayViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
LaunchedEffect(activity) {
@@ -174,6 +185,7 @@ fun App(
) {
CompositionLocalProvider(
LocalNostrViewModel provides nostrViewModel,
LocalRelayViewModel provides relayViewModel,
LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel,
LocalSnackbarHostState provides snackbarHostState,

View File

@@ -4,12 +4,15 @@ import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class ExternalSignerLauncher {
class ExternalSignerLauncher(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {
private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null
private val mutex = Mutex()
@@ -19,7 +22,7 @@ class ExternalSignerLauncher {
}
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
withContext(Dispatchers.Main) {
withContext(mainDispatcher) {
val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")

View File

@@ -17,6 +17,7 @@ import su.reya.coop.repository.MediaRepository
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import su.reya.coop.viewmodel.RelayViewModel
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
@@ -29,6 +30,7 @@ class MainActivity : ComponentActivity() {
private val storage = AppStore(this@MainActivity)
private val mediaRepository = MediaRepository()
private val nostrViewModel = NostrViewModel(NostrManager.instance, mediaRepository)
private val relayViewModel = RelayViewModel(NostrManager.instance)
private val chatViewModel = ChatViewModel(NostrManager.instance, mediaRepository)
private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
@@ -38,6 +40,7 @@ class MainActivity : ComponentActivity() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class")
@@ -47,6 +50,7 @@ class MainActivity : ComponentActivity() {
}
private val nostrViewModel: NostrViewModel by viewModels { factory }
private val relayViewModel: RelayViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory }
private val authViewModel: AuthViewModel by viewModels { factory }
@@ -93,6 +97,7 @@ class MainActivity : ComponentActivity() {
setContent {
App(
nostrViewModel = nostrViewModel,
relayViewModel = relayViewModel,
chatViewModel = chatViewModel,
authViewModel = authViewModel,
)

View File

@@ -14,6 +14,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -27,6 +28,8 @@ private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null

View File

@@ -265,10 +265,8 @@ fun AddContactDialog(onDismissRequest: () -> Unit) {
},
actions = {
IconButton(onClick = {
scope.launch {
val success = nostrViewModel.addContact(contact)
if (success) onDismissRequest()
}
nostrViewModel.addContact(contact)
onDismissRequest()
}) {
Icon(
painter = painterResource(Res.drawable.ic_check),

View File

@@ -93,6 +93,7 @@ import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
@@ -114,6 +115,7 @@ fun HomeScreen() {
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val relayViewModel = LocalRelayViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
@@ -123,7 +125,7 @@ fun HomeScreen() {
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
@@ -319,11 +321,9 @@ fun HomeScreen() {
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
}
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
@@ -469,7 +469,7 @@ fun HomeScreen() {
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
onDismissRequest = { relayViewModel.dismissRelayWarning() },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
@@ -573,15 +573,9 @@ fun HomeScreen() {
TextButton(
enabled = !isBusy,
onClick = {
scope.launch {
isBusy = true
try {
nostrViewModel.refetchMsgRelays()
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
}
isBusy = false
}
isBusy = true
relayViewModel.refetchMsgRelays()
isBusy = false
},
modifier = Modifier
.weight(1f)
@@ -595,10 +589,8 @@ fun HomeScreen() {
Button(
enabled = !isBusy,
onClick = {
scope.launch {
nostrViewModel.useDefaultMsgRelayList()
sheetState.hide()
}
relayViewModel.useDefaultMsgRelayList()
scope.launch { sheetState.hide() }
},
modifier = Modifier
.weight(1f)

View File

@@ -96,14 +96,16 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey)
}
} else if (query.contains("@")) {
val pubkey = nostrViewModel.searchByAddress(query)
if (pubkey != null) {
selectedReceivers.add(pubkey)
nostrViewModel.searchByAddress(query) { pubkey ->
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
}
} else {
val results = nostrViewModel.searchByNostr(query)
searchResults.clear()
searchResults.addAll(results)
nostrViewModel.searchByNostr(query) { results ->
searchResults.clear()
searchResults.addAll(results)
}
}
query = ""

View File

@@ -56,6 +56,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_check
@@ -67,14 +68,16 @@ import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalRelayViewModel
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val relayViewModel = LocalRelayViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
@@ -96,8 +99,25 @@ fun RelayScreen() {
var relayToDelete by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
relayList.putAll(nostrViewModel.currentUserRelayList())
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
relayViewModel.loadCurrentUserRelayList()
relayViewModel.loadCurrentUserMsgRelayList()
}
val loadedRelayList by relayViewModel.currentUserRelayList.collectAsStateWithLifecycle()
val loadedMsgRelayList by relayViewModel.currentUserMsgRelayList.collectAsStateWithLifecycle()
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
relayList.clear()
relayList.putAll(loadedRelayList)
}
}
LaunchedEffect(loadedMsgRelayList) {
if (loadedMsgRelayList.isNotEmpty()) {
msgRelayList.clear()
msgRelayList.addAll(loadedMsgRelayList)
}
}
Scaffold(
@@ -314,20 +334,16 @@ fun RelayScreen() {
confirmButton = {
TextButton(
onClick = {
scope.launch {
if (msgRelayList.size == 1) {
if (msgRelayList.size == 1) {
scope.launch {
snackbarHostState.showSnackbar("You must have at least one relay")
relayToDelete = null
return@launch
}
try {
nostrViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
} catch (e: Exception) {
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
}
relayToDelete = null
return@TextButton
}
relayViewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
}
) {
Text("Confirm")
@@ -349,7 +365,7 @@ fun AddRelayDialog(
onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) {
val nostrViewModel = LocalNostrViewModel.current
val relayViewModel = LocalRelayViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
@@ -397,26 +413,24 @@ fun AddRelayDialog(
},
actions = {
IconButton(onClick = {
scope.launch {
if (!isError) {
when (selected) {
"Messaging" -> {
nostrViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
nostrViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
nostrViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
if (!isError) {
when (selected) {
"Messaging" -> {
relayViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
relayViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
relayViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
onDismissRequest()
}
onDismissRequest()
}
}) {
Icon(

View File

@@ -101,11 +101,9 @@ fun RequestListScreen() {
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
}
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(

View File

@@ -18,7 +18,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -29,7 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
@@ -46,7 +44,6 @@ fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
@@ -56,14 +53,12 @@ fun ScreenerCard(room: Room) {
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {
scope.launch {
// Check contact
nostrViewModel.verifyContact(pubkey).let { isContact = it }
// Get mutual contacts
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
// Get the last activity
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
// Check contact
nostrViewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts
nostrViewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity
nostrViewModel.verifyActivity(pubkey) { lastActivity = it }
}
Column(

View File

@@ -59,6 +59,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -77,7 +78,11 @@ import su.reya.coop.shared.Avatar
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatScreen(id: Long, screening: Boolean = false) {
fun ChatScreen(
id: Long,
screening: Boolean = false,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
@@ -121,7 +126,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
val sendFile = { uri: Uri ->
scope.launch {
// Read file on IO dispatcher
val file = withContext(Dispatchers.IO) {
val file = withContext(coroutineDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
@@ -149,12 +154,13 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
LaunchedEffect(id) {
// Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
chatViewModel.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages)
// Stop loading spinner
loading = false
// Stop loading spinner
loading = false
}
// Get msg relays for each member
chatViewModel.chatRoomConnect(id)

View File

@@ -54,6 +54,7 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -69,7 +70,8 @@ fun ProfileEditor(
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
@@ -269,7 +271,7 @@ fun ProfileEditor(
scope.launch {
isBusy = true
try {
val bytes = withContext(Dispatchers.IO) {
val bytes = withContext(ioDispatcher) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}