chore: merge the develop branch into master #1
@@ -130,7 +130,6 @@ fun App(dbPath: String) {
|
||||
input.readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.createIdentity(name, bio, picture, contentType)
|
||||
}
|
||||
)
|
||||
@@ -142,7 +141,10 @@ fun App(dbPath: String) {
|
||||
}
|
||||
composable<Screen.Chat> { backStackEntry ->
|
||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
||||
ChatScreen(id = chat.id)
|
||||
ChatScreen(
|
||||
id = chat.id,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,233 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_avatar
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Event
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.shared.displayNameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(id: Long) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("Chat Screen (ID: $id)")
|
||||
fun ChatScreen(
|
||||
id: Long,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val room = viewModel.getChatRoom(id)
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var messages by remember { mutableStateOf<List<Event>>(emptyList()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(id) {
|
||||
loading = true
|
||||
messages = viewModel.getChatRoomMessages(id)
|
||||
loading = false
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box {
|
||||
if (!picture.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = "Room Avatar",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_avatar),
|
||||
contentDescription = "User"
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
if (loading) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
reverseLayout = true
|
||||
) {
|
||||
items(messages.toList(), key = { it.id().toBech32() }) { event ->
|
||||
ChatMessage(event)
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
// TODO: Implement send logic
|
||||
text = ""
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatMessage(
|
||||
event: Event
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser()
|
||||
val isMine = event.author() == currentUser
|
||||
|
||||
val bubbleShape = if (isMine) {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
||||
} else {
|
||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
||||
}
|
||||
|
||||
val alignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
val containerColor =
|
||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer
|
||||
val contentColor =
|
||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = alignment
|
||||
) {
|
||||
Surface(
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = bubbleShape,
|
||||
modifier = Modifier.widthIn(max = 280.dp)
|
||||
) {
|
||||
Text(
|
||||
text = event.content(),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.imePadding(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = CircleShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.shared.displayNameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@@ -240,37 +242,11 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val memberMetadataList = room.members.map { pubkey ->
|
||||
viewModel.getMetadata(pubkey).collectAsState()
|
||||
}
|
||||
|
||||
val displayName = if (!room.subject.isNullOrBlank()) {
|
||||
room.subject
|
||||
} else if (room.isGroup()) {
|
||||
val profiles = memberMetadataList.map { it.value?.asRecord() }
|
||||
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
|
||||
|
||||
var combined = names.joinToString(", ")
|
||||
if (profiles.size > 2) {
|
||||
combined += ", +${profiles.size - 2}"
|
||||
}
|
||||
combined.ifBlank { "Unknown group" }
|
||||
} else {
|
||||
val firstMember = room.members.firstOrNull()
|
||||
val profile = memberMetadataList.firstOrNull()?.value?.asRecord()
|
||||
profile?.name ?: profile?.displayName ?: firstMember?.short() ?: "Unknown"
|
||||
}
|
||||
|
||||
val firstMemberMetadata by if (room.members.isNotEmpty()) {
|
||||
viewModel.getMetadata(room.members.first()).collectAsState()
|
||||
} else {
|
||||
remember { mutableStateOf(null) }
|
||||
}
|
||||
val picture = firstMemberMetadata?.asRecord()?.picture
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable { onClick },
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
if (!picture.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import su.reya.coop.NostrViewModel
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.short
|
||||
|
||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
|
||||
|
||||
val memberFlows = members.map { viewModel.getMetadata(it) }
|
||||
|
||||
return combine(memberFlows) { metadataArray ->
|
||||
if (isGroup()) {
|
||||
val profiles = metadataArray.map { it?.asRecord() }
|
||||
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
|
||||
var combined = names.joinToString(", ")
|
||||
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
|
||||
combined.ifBlank { "Unknown group" }
|
||||
} else {
|
||||
val profile = metadataArray.firstOrNull()?.asRecord()
|
||||
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
||||
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
|
||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
@@ -182,9 +182,8 @@ class Nostr {
|
||||
when (notification) {
|
||||
is ClientNotification.Message -> {
|
||||
val relayUrl = notification.relayUrl
|
||||
val message = notification.message.asEnum()
|
||||
|
||||
when (message) {
|
||||
|
||||
when (val message = notification.message.asEnum()) {
|
||||
is RelayMessageEnum.EventMsg -> {
|
||||
val event = message.event
|
||||
|
||||
@@ -396,16 +395,29 @@ class Nostr {
|
||||
}
|
||||
|
||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||
val filter = Filter()
|
||||
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||
.authors(keys)
|
||||
.limit(keys.size.toULong())
|
||||
try {
|
||||
val limit = keys.size.toULong();
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
val metadataRelay = RelayUrl.parse("wss://user.kindpag.es")
|
||||
val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter)))
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
// Construct a filter for metadata events
|
||||
val filter = Filter()
|
||||
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||
.authors(keys)
|
||||
.limit(limit)
|
||||
|
||||
client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts)
|
||||
// Construct a target that includes all filters
|
||||
val target =
|
||||
ReqTarget.manual(
|
||||
mapOf(
|
||||
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
|
||||
RelayUrl.parse("wss://relay.primal.net") to listOf(filter)
|
||||
)
|
||||
)
|
||||
|
||||
client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getChatRooms(): Set<Room>? {
|
||||
@@ -468,12 +480,11 @@ class Nostr {
|
||||
|
||||
val sendEvents = client?.database()?.query(sendFilter)
|
||||
val recvEvents = client?.database()?.query(recvFilter)
|
||||
val events = sendEvents?.merge(recvEvents!!)?.toVec()
|
||||
|
||||
sendEvents?.merge(recvEvents!!)?.toVec()
|
||||
return events ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||
}
|
||||
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ class NostrViewModel(
|
||||
}
|
||||
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
if (batch.size >= 20 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
val keysToRequest = batch.toList()
|
||||
batch.clear()
|
||||
nostr.fetchMetadataBatch(keysToRequest)
|
||||
@@ -271,6 +271,11 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
?: throw IllegalArgumentException("Room not found")
|
||||
}
|
||||
|
||||
fun getChatRooms() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user