feat: add basic support for file upload via blossom #32
@@ -36,7 +36,8 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.App.Starting">
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -7,10 +10,13 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
@@ -29,6 +36,7 @@ import androidx.compose.material3.LoadingIndicator
|
|||||||
import androidx.compose.material3.MaterialShapes
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -50,17 +58,21 @@ 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
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
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_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_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
|
||||||
@@ -81,13 +93,16 @@ import su.reya.coop.shared.nameFlow
|
|||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@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 chatViewModel = LocalChatViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val profileViewModel = LocalProfileViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
// Get chat room by ID
|
// Get chat room by ID
|
||||||
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
@@ -108,10 +123,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentUser = accountViewModel.nostr.signer.currentUser
|
|
||||||
|
|
||||||
val displayName by remember(room, currentUser) {
|
val displayName by remember(room) {
|
||||||
room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...")
|
room?.nameFlow(profileViewModel) ?: flowOf("Loading...")
|
||||||
}.collectAsStateWithLifecycle("Loading...")
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
val picture by remember(room) {
|
val picture by remember(room) {
|
||||||
@@ -123,17 +137,35 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
var requireScreening by remember { mutableStateOf(screening) }
|
var requireScreening by remember { mutableStateOf(screening) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
|
|
||||||
val groupedMessages = remember(messages.toList()) {
|
val groupedMessages = remember(messages.toList()) {
|
||||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(id) {
|
val sendFile = { uri: Uri ->
|
||||||
// Start loading spinner
|
scope.launch {
|
||||||
loading = true
|
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
|
// Get messages
|
||||||
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||||
messages.clear()
|
messages.clear()
|
||||||
@@ -165,6 +197,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -291,12 +324,14 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { navigator.goBack() },
|
onClick = { navigator.goBack() },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Reject",
|
text = "Reject",
|
||||||
@@ -305,7 +340,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
}
|
}
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { requireScreening = false },
|
onClick = { requireScreening = false },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Accept",
|
text = "Accept",
|
||||||
@@ -322,6 +359,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
|
|||||||
onSend = {
|
onSend = {
|
||||||
chatViewModel.sendMessage(id, text)
|
chatViewModel.sendMessage(id, text)
|
||||||
text = ""
|
text = ""
|
||||||
|
},
|
||||||
|
onUpload = {
|
||||||
|
launcher.launch("image/*")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -532,23 +572,31 @@ fun ChatMessage(
|
|||||||
fun ChatInput(
|
fun ChatInput(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit
|
onSend: () -> Unit,
|
||||||
|
onUpload: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
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_add_circle),
|
||||||
|
contentDescription = "Upload",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
@@ -566,4 +614,3 @@ fun ChatInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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 {
|
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