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
9 changed files with 161 additions and 85 deletions

View File

@@ -36,7 +36,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting">
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M440,680L520,680L520,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -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
@@ -7,10 +10,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -19,6 +25,7 @@ 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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
@@ -29,6 +36,7 @@ import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -50,17 +58,21 @@ 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
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_add_circle
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_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
@@ -81,13 +93,16 @@ import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow
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()
val listState = rememberLazyListState()
// Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
@@ -108,10 +123,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
return
}
val currentUser = accountViewModel.nostr.signer.currentUser
val displayName by remember(room, currentUser) {
room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...")
val displayName by remember(room) {
room?.nameFlow(profileViewModel) ?: flowOf("Loading...")
}.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) {
@@ -123,17 +137,35 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
var newOtherMessages by remember { mutableIntStateOf(0) }
var requireScreening by remember { mutableStateOf(screening) }
val listState = rememberLazyListState()
val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) {
messages.groupBy { it.createdAt().formatAsGroupHeader() }
}
LaunchedEffect(id) {
// Start loading spinner
loading = true
val sendFile = { uri: Uri ->
scope.launch {
try {
// Read file
val file = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message
chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
}
}
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendFile(it) }
}
LaunchedEffect(id) {
// Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id)
messages.clear()
@@ -165,6 +197,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
@@ -291,12 +324,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Reject",
@@ -305,7 +340,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
}
FilledTonalButton(
onClick = { requireScreening = false },
modifier = Modifier.weight(1f)
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Accept",
@@ -322,6 +359,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
onSend = {
chatViewModel.sendMessage(id, text)
text = ""
},
onUpload = {
launcher.launch("image/*")
}
)
}
@@ -532,23 +572,31 @@ fun ChatMessage(
fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit
onSend: () -> Unit,
onUpload: () -> Unit
) {
Surface(modifier = Modifier.fillMaxWidth()) {
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_add_circle),
contentDescription = "Upload",
)
}
},
)
Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton(
@@ -566,4 +614,3 @@ fun ChatInput(
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -133,6 +133,7 @@ class AccountViewModel(
suspend fun importIdentity(secret: String) {
appViewModel.setBusy(true)
try {
val signer = createSigner(secret)
nostr.setSigner(signer)
@@ -150,7 +151,6 @@ class AccountViewModel(
bio: String?,
picture: ByteArray?,
contentType: String? = "image/jpeg",
profileViewModel: ProfileViewModel
) {
appViewModel.setBusy(true)
@@ -158,7 +158,7 @@ class AccountViewModel(
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)

View File

@@ -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 {

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()) {
appViewModel.showError("Message cannot be empty")
return
@@ -173,6 +177,24 @@ class ChatViewModel(
}
}
suspend fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
try {
val uri = appViewModel.blossomUpload(file, contentType)
?: throw IllegalArgumentException("Failed to upload file")
sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id]
return if (giftWrapId != null) {

View File

@@ -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}")