add upload button
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,7 +151,6 @@ 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)
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ class AccountViewModel(
|
|||||||
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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user