diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 63a552c..a569767 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -1,5 +1,8 @@ package su.reya.coop.screens +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -55,6 +58,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -63,9 +67,12 @@ import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_check_circle +import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_send +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp @@ -89,11 +96,13 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatScreen(id: Long, screening: Boolean = false) { + val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val accountViewModel = LocalAccountViewModel.current val chatViewModel = LocalChatViewModel.current val profileViewModel = LocalProfileViewModel.current + val scope = rememberCoroutineScope() // Get chat room by ID val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() @@ -128,6 +137,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { var loading by remember { mutableStateOf(true) } var newOtherMessages by remember { mutableIntStateOf(0) } var requireScreening by remember { mutableStateOf(screening) } + var upload by remember { mutableStateOf(null) } val listState = rememberLazyListState() val messages = remember { mutableStateListOf() } @@ -136,6 +146,20 @@ fun ChatScreen(id: Long, screening: Boolean = false) { messages.groupBy { it.createdAt().formatAsGroupHeader() } } + val sendFile = { uri: Uri -> + scope.launch { + val bytes = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } + val type = context.contentResolver.getType(uri) + chatViewModel.sendFileMessage(id, bytes, type) + } + } + + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + uri?.let { sendFile(it) } + } + LaunchedEffect(id) { // Start loading spinner loading = true @@ -333,6 +357,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) { onSend = { chatViewModel.sendMessage(id, text) text = "" + }, + onUpload = { + launcher.launch("image/*") } ) } @@ -543,22 +570,31 @@ fun ChatMessage( fun ChatInput( value: String, onValueChange: (String) -> Unit, - onSend: () -> Unit + onSend: () -> Unit, + onUpload: () -> Unit ) { Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.Bottom ) { TextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text("Message") }, + modifier = Modifier.weight(1f), shape = RoundedCornerShape(28.dp), colors = TextFieldDefaults.colors( focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent ), - modifier = Modifier.weight(1f) + value = value, + onValueChange = onValueChange, + placeholder = { Text("Message") }, + leadingIcon = { + IconButton(onClick = onUpload) { + Icon( + painter = painterResource(Res.drawable.ic_plus), + contentDescription = "Upload", + ) + } + }, ) Spacer(modifier = Modifier.size(8.dp)) FilledTonalIconButton( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 88eca83..4b98253 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -28,7 +28,7 @@ fun NewIdentityScreen() { onBack = { navigator.goBack() }, onConfirm = { name, bio, bytes, type -> scope.launch { - accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel) + accountViewModel.createIdentity(name, bio, bytes, type) navigator.navigate(Screen.Home) } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt index 2267b2c..9aab16a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/shared/ProfileEditor.kt @@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable 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 @@ -53,7 +54,6 @@ 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.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -75,6 +75,8 @@ fun ProfileEditor( val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + var name by remember(initialName) { mutableStateOf(initialName) } var bio by remember(initialBio) { mutableStateOf(initialBio) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) } @@ -264,7 +266,6 @@ fun ProfileEditor( .fillMaxWidth() .size(ButtonDefaults.MediumContainerHeight), onClick = { - val scope = CoroutineScope(Dispatchers.Main) scope.launch { val bytes = withContext(Dispatchers.IO) { (picture as? Uri)?.let { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt index e8b8d2e..1fe5b7f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AccountViewModel.kt @@ -133,6 +133,7 @@ class AccountViewModel( suspend fun importIdentity(secret: String) { appViewModel.setBusy(true) + try { val signer = createSigner(secret) nostr.setSigner(signer) @@ -150,15 +151,14 @@ class AccountViewModel( bio: String?, picture: ByteArray?, contentType: String? = "image/jpeg", - profileViewModel: ProfileViewModel ) { appViewModel.setBusy(true) - + val keys = Keys.generate() val secret = keys.secretKey().toBech32() try { - val avatarUrl = picture?.let { profileViewModel.blossomUpload(it, contentType) } + val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) } nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) // Set credentials in persistent storage secretStore.set("user_signer", secret) diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt index ae383bf..e2eff28 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/AppViewModel.kt @@ -2,6 +2,9 @@ package su.reya.coop.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -9,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import su.reya.coop.blossom.BlossomClient import su.reya.coop.nostr.Nostr import su.reya.coop.storage.SecretStorage @@ -53,6 +58,29 @@ class AppViewModel( } } + suspend fun blossomUpload(file: ByteArray, contentType: String? = "image/jpeg"): String? { + val blossom = BlossomClient( + url = "https://blossom.band", + client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) + } + } + ) + + val descriptor = blossom.upload( + file = file, + contentType = contentType, + signer = nostr.signer.get() + ) + + return descriptor?.url + } + override fun onCleared() { super.onCleared() viewModelScope.launch { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt index 74dc5b9..4a3a729 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ChatViewModel.kt @@ -149,7 +149,11 @@ class ChatViewModel( } } - fun sendMessage(roomId: Long, message: String, replies: List = emptyList()) { + fun sendMessage( + roomId: Long, + message: String, + replies: List = emptyList() + ) { if (message.isEmpty()) { appViewModel.showError("Message cannot be empty") return @@ -173,6 +177,23 @@ class ChatViewModel( } } + fun sendFileMessage( + roomId: Long, + file: ByteArray?, + contentType: String? = "image/jpeg", + replies: List = emptyList() + ) { + if (file == null) return + viewModelScope.launch { + try { + val uri = appViewModel.blossomUpload(file, contentType) ?: return@launch + sendMessage(roomId, uri, replies) + } catch (e: Exception) { + appViewModel.showError("Error: ${e.message}") + } + } + } + fun isMessageSent(id: EventId): Boolean { val giftWrapId = nostr.messages.rumorMap[id] return if (giftWrapId != null) { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt index b015e22..3553c50 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodels/ProfileViewModel.kt @@ -2,9 +2,6 @@ package su.reya.coop.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import io.ktor.client.HttpClient -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -13,11 +10,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull -import kotlinx.serialization.json.Json import rust.nostr.sdk.Metadata import rust.nostr.sdk.PublicKey import rust.nostr.sdk.Timestamp -import su.reya.coop.blossom.BlossomClient import su.reya.coop.nostr.Nostr import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds @@ -110,45 +105,18 @@ class ProfileViewModel( } } - suspend fun blossomUpload(file: ByteArray, contentType: String?): String? { - try { - val blossom = BlossomClient( - url = "https://blossom.band", - client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } - } - ) - - val descriptor = blossom.upload( - file = file, - contentType = contentType, - signer = nostr.signer.get() - ) - - return descriptor?.url - } catch (e: Exception) { - appViewModel.showError("Error: ${e.message}") - return null - } - } - suspend fun updateProfile( name: String? = null, bio: String? = null, picture: ByteArray? = null, - contentType: String? = null + contentType: String? = "image/jpeg" ) { appViewModel.setBusy(true) try { - val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") } + val avatarUrl = picture?.let { appViewModel.blossomUpload(it, contentType) } val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl) val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") + updateMetadata(currentUser, newMetadata) } catch (e: Exception) { appViewModel.showError("Error: ${e.message}")