chore: merge the develop branch into master #1
@@ -130,7 +130,6 @@ fun App(dbPath: String) {
|
|||||||
input.readBytes()
|
input.readBytes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.createIdentity(name, bio, picture, contentType)
|
viewModel.createIdentity(name, bio, picture, contentType)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -142,7 +141,10 @@ fun App(dbPath: String) {
|
|||||||
}
|
}
|
||||||
composable<Screen.Chat> { backStackEntry ->
|
composable<Screen.Chat> { backStackEntry ->
|
||||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
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
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.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.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.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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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
|
@Composable
|
||||||
fun ChatScreen(id: Long) {
|
fun ChatScreen(
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
id: Long,
|
||||||
Text("Chat Screen (ID: $id)")
|
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.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.shared.displayNameFlow
|
||||||
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@@ -240,37 +242,11 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||||
val memberMetadataList = room.members.map { pubkey ->
|
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||||
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
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable { onClick },
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
if (!picture.isNullOrBlank()) {
|
if (!picture.isNullOrBlank()) {
|
||||||
AsyncImage(
|
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) {
|
when (notification) {
|
||||||
is ClientNotification.Message -> {
|
is ClientNotification.Message -> {
|
||||||
val relayUrl = notification.relayUrl
|
val relayUrl = notification.relayUrl
|
||||||
val message = notification.message.asEnum()
|
|
||||||
|
|
||||||
when (message) {
|
when (val message = notification.message.asEnum()) {
|
||||||
is RelayMessageEnum.EventMsg -> {
|
is RelayMessageEnum.EventMsg -> {
|
||||||
val event = message.event
|
val event = message.event
|
||||||
|
|
||||||
@@ -396,16 +395,29 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val limit = keys.size.toULong();
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
// Construct a filter for metadata events
|
||||||
val filter = Filter()
|
val filter = Filter()
|
||||||
.kind(Kind.fromStd(KindStandard.METADATA))
|
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||||
.authors(keys)
|
.authors(keys)
|
||||||
.limit(keys.size.toULong())
|
.limit(limit)
|
||||||
|
|
||||||
val metadataRelay = RelayUrl.parse("wss://user.kindpag.es")
|
// Construct a target that includes all filters
|
||||||
val target = ReqTarget.manual(mapOf(metadataRelay to listOf(filter)))
|
val target =
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
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)
|
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>? {
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
@@ -468,12 +480,11 @@ class Nostr {
|
|||||||
|
|
||||||
val sendEvents = client?.database()?.query(sendFilter)
|
val sendEvents = client?.database()?.query(sendFilter)
|
||||||
val recvEvents = client?.database()?.query(recvFilter)
|
val recvEvents = client?.database()?.query(recvFilter)
|
||||||
|
val events = sendEvents?.merge(recvEvents!!)?.toVec()
|
||||||
|
|
||||||
sendEvents?.merge(recvEvents!!)?.toVec()
|
return events ?: emptyList()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
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()
|
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()
|
val keysToRequest = batch.toList()
|
||||||
batch.clear()
|
batch.clear()
|
||||||
nostr.fetchMetadataBatch(keysToRequest)
|
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() {
|
fun getChatRooms() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user