feat: add basic support for file upload via blossom #32

Merged
reya merged 3 commits from feat/image-upload into master 2026-06-30 08:11:20 +00:00
7 changed files with 101 additions and 47 deletions
Showing only changes of commit 0c6b23b9e6 - Show all commits

View File

@@ -1,5 +1,8 @@
package su.reya.coop.screens 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -55,6 +58,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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_arrow_back
import coop.composeapp.generated.resources.ic_cancel import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle import coop.composeapp.generated.resources.ic_check_circle
import coop.composeapp.generated.resources.ic_plus
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
@@ -89,11 +96,13 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatScreen(id: Long, screening: Boolean = false) { fun ChatScreen(id: Long, screening: Boolean = false) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val accountViewModel = LocalAccountViewModel.current val accountViewModel = LocalAccountViewModel.current
val chatViewModel = LocalChatViewModel.current val chatViewModel = LocalChatViewModel.current
val profileViewModel = LocalProfileViewModel.current val profileViewModel = LocalProfileViewModel.current
val scope = rememberCoroutineScope()
// Get chat room by ID // Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
@@ -128,6 +137,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) } var newOtherMessages by remember { mutableIntStateOf(0) }
var requireScreening by remember { mutableStateOf(screening) } var requireScreening by remember { mutableStateOf(screening) }
var upload by remember { mutableStateOf<Uri?>(null) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val messages = remember { mutableStateListOf<UnsignedEvent>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
@@ -136,6 +146,20 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
messages.groupBy { it.createdAt().formatAsGroupHeader() } 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) { LaunchedEffect(id) {
// Start loading spinner // Start loading spinner
loading = true loading = true
@@ -333,6 +357,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
onSend = { onSend = {
chatViewModel.sendMessage(id, text) chatViewModel.sendMessage(id, text)
text = "" text = ""
},
onUpload = {
launcher.launch("image/*")
} }
) )
} }
@@ -543,22 +570,31 @@ fun ChatMessage(
fun ChatInput( fun ChatInput(
value: String, value: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit onSend: () -> Unit,
onUpload: () -> Unit
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
TextField( TextField(
value = value, modifier = Modifier.weight(1f),
onValueChange = onValueChange,
placeholder = { Text("Message") },
shape = RoundedCornerShape(28.dp), shape = RoundedCornerShape(28.dp),
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent, focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = 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)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton( FilledTonalIconButton(

View File

@@ -28,7 +28,7 @@ fun NewIdentityScreen() {
onBack = { navigator.goBack() }, onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type -> onConfirm = { name, bio, bytes, type ->
scope.launch { scope.launch {
accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel) accountViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home) navigator.navigate(Screen.Home)
} }
} }

View File

@@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,7 +54,6 @@ import coil3.compose.AsyncImage
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
import coop.composeapp.generated.resources.ic_plus import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -75,6 +75,8 @@ fun ProfileEditor(
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
var name by remember(initialName) { mutableStateOf(initialName) } var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) } var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) } var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
@@ -264,7 +266,6 @@ fun ProfileEditor(
.fillMaxWidth() .fillMaxWidth()
.size(ButtonDefaults.MediumContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
onClick = { onClick = {
val scope = CoroutineScope(Dispatchers.Main)
scope.launch { scope.launch {
val bytes = withContext(Dispatchers.IO) { val bytes = withContext(Dispatchers.IO) {
(picture as? Uri)?.let { (picture as? Uri)?.let {

View File

@@ -133,6 +133,7 @@ class AccountViewModel(
suspend fun importIdentity(secret: String) { suspend fun importIdentity(secret: String) {
appViewModel.setBusy(true) appViewModel.setBusy(true)
try { try {
val signer = createSigner(secret) val signer = createSigner(secret)
nostr.setSigner(signer) nostr.setSigner(signer)
@@ -150,15 +151,14 @@ class AccountViewModel(
bio: String?, bio: String?,
picture: ByteArray?, picture: ByteArray?,
contentType: String? = "image/jpeg", contentType: String? = "image/jpeg",
profileViewModel: ProfileViewModel
) { ) {
appViewModel.setBusy(true) appViewModel.setBusy(true)
val keys = Keys.generate() val keys = Keys.generate()
val secret = keys.secretKey().toBech32() val secret = keys.secretKey().toBech32()
try { 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) nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Set credentials in persistent storage // Set credentials in persistent storage
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)

View File

@@ -2,6 +2,9 @@ package su.reya.coop.viewmodels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.NonCancellable
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -9,6 +12,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.nostr.Nostr
import su.reya.coop.storage.SecretStorage 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() { override fun onCleared() {
super.onCleared() super.onCleared()
viewModelScope.launch { viewModelScope.launch {

View File

@@ -149,7 +149,11 @@ class ChatViewModel(
} }
} }
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) { fun sendMessage(
roomId: Long,
message: String,
replies: List<EventId> = emptyList()
) {
if (message.isEmpty()) { if (message.isEmpty()) {
appViewModel.showError("Message cannot be empty") appViewModel.showError("Message cannot be empty")
return return
@@ -173,6 +177,23 @@ class ChatViewModel(
} }
} }
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = 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 { fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id] val giftWrapId = nostr.messages.rumorMap[id]
return if (giftWrapId != null) { return if (giftWrapId != null) {

View File

@@ -2,9 +2,6 @@ package su.reya.coop.viewmodels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -13,11 +10,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.Metadata import rust.nostr.sdk.Metadata
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.nostr.Nostr import su.reya.coop.nostr.Nostr
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds 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( suspend fun updateProfile(
name: String? = null, name: String? = null,
bio: String? = null, bio: String? = null,
picture: ByteArray? = null, picture: ByteArray? = null,
contentType: String? = null contentType: String? = "image/jpeg"
) { ) {
appViewModel.setBusy(true) appViewModel.setBusy(true)
try { 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 newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found") val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
updateMetadata(currentUser, newMetadata) updateMetadata(currentUser, newMetadata)
} catch (e: Exception) { } catch (e: Exception) {
appViewModel.showError("Error: ${e.message}") appViewModel.showError("Error: ${e.message}")