add upload button
This commit is contained in:
@@ -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<Uri?>(null) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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 {
|
||||
val giftWrapId = nostr.messages.rumorMap[id]
|
||||
return if (giftWrapId != null) {
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user