chore: merge the develop branch into master #1
@@ -47,6 +47,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
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_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
@@ -56,6 +57,7 @@ import su.reya.coop.roomId
|
|||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.displayNameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
@@ -91,7 +93,16 @@ fun ChatScreen(
|
|||||||
messages.addAll(initialMessages)
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
// Get msg relays for each member
|
// Get msg relays for each member
|
||||||
viewModel.chatRoomConnect(id)
|
val results = viewModel.chatRoomConnect(id)
|
||||||
|
results.forEach { (member, relays) ->
|
||||||
|
if (relays.isNotEmpty()) {
|
||||||
|
val metadata = viewModel.getMetadata(member).first { it != null }
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
val name = profile?.displayName ?: profile?.name ?: member.short()
|
||||||
|
|
||||||
|
snackbarHostState.showSnackbar("Connected to messaging relays for $name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Stop loading spinner
|
// Stop loading spinner
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -113,7 +124,11 @@ fun ChatScreen(
|
|||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box {
|
if (loading) {
|
||||||
|
LoadingIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = picture,
|
||||||
description = displayName,
|
description = displayName,
|
||||||
@@ -148,19 +163,12 @@ fun ChatScreen(
|
|||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
if (loading) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
|
if (groupedMessages.isNotEmpty()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -169,7 +177,9 @@ fun ChatScreen(
|
|||||||
reverseLayout = true
|
reverseLayout = true
|
||||||
) {
|
) {
|
||||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||||
items(messagesInGroup, key = { it.id()?.toBech32()!! }) { event ->
|
items(
|
||||||
|
messagesInGroup,
|
||||||
|
key = { it.id()?.toBech32()!! }) { event ->
|
||||||
ChatMessage(event)
|
ChatMessage(event)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
@@ -177,6 +187,25 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "No messages yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your conversations will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ChatInput(
|
ChatInput(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
@@ -188,7 +217,6 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,10 +251,10 @@ fun ChatMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val containerColor =
|
val containerColor =
|
||||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer
|
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
||||||
|
|
||||||
val contentColor =
|
val contentColor =
|
||||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer
|
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -360,19 +360,19 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
|
||||||
"Messaging Relays",
|
|
||||||
"Spam Filter",
|
|
||||||
"Contacts",
|
|
||||||
"Settings",
|
|
||||||
"About"
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BottomMenuList() {
|
fun BottomMenuList() {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val defaultMenuList = listOf(
|
||||||
|
"Messaging Relays" to { },
|
||||||
|
"Spam Filter" to { },
|
||||||
|
"Contacts" to { },
|
||||||
|
"Settings" to { },
|
||||||
|
"About" to { }
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -381,15 +381,14 @@ fun BottomMenuList() {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
) {
|
) {
|
||||||
defaultMenuList.forEachIndexed { index, item ->
|
defaultMenuList.forEachIndexed { index, (title, action) ->
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
checked = false,
|
onClick = { action() },
|
||||||
onCheckedChange = { },
|
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = defaultMenuList.size
|
count = defaultMenuList.size
|
||||||
),
|
),
|
||||||
content = { Text(text = item) },
|
content = { Text(text = title) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,8 +70,6 @@ class Nostr {
|
|||||||
private set
|
private set
|
||||||
var deviceSigner: AsyncNostrSigner? = null
|
var deviceSigner: AsyncNostrSigner? = null
|
||||||
private set
|
private set
|
||||||
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
|
|
||||||
private set
|
|
||||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
private set
|
private set
|
||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
@@ -253,11 +251,15 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {
|
||||||
|
/* Ignore other event kinds */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,20 +308,10 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
||||||
// Get all gift wrap events for current user
|
// Get all gift wrap events for the current user
|
||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
getUserMessages(msgRelayList = event)
|
getUserMessages(msgRelayList = event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to all msg relays for the currently active chat room
|
|
||||||
if (id.startsWith("room-")) {
|
|
||||||
launch {
|
|
||||||
chatRoomAuth(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the relay list for future use
|
|
||||||
setMsgRelay(pubkey = event.author(), event = event)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||||
@@ -368,20 +360,15 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is ClientNotification.NewEvent -> {
|
|
||||||
// TODO: Handle new event
|
|
||||||
}
|
|
||||||
|
|
||||||
is ClientNotification.Shutdown -> {
|
is ClientNotification.Shutdown -> {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setMsgRelay(pubkey: PublicKey, event: Event) {
|
else -> {
|
||||||
val relays = nip17ExtractRelayList(event)
|
/* Ignore other message types */
|
||||||
msgRelayList = msgRelayList + (pubkey to relays)
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
@@ -644,33 +631,49 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun chatRoomConnect(id: Long, members: List<PublicKey>) {
|
suspend fun chatRoomConnect(members: List<PublicKey>): Map<PublicKey, List<RelayUrl>> {
|
||||||
try {
|
try {
|
||||||
|
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
|
||||||
|
|
||||||
members.forEach { member ->
|
members.forEach { member ->
|
||||||
|
results[member] = mutableListOf<RelayUrl>()
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
client?.subscribe(
|
val stream = client?.streamEvents(
|
||||||
target = ReqTarget.auto(listOf(filter)),
|
target = ReqTarget.auto(listOf(filter)),
|
||||||
closeOn = opts,
|
id = "room-${member.toBech32().substring(0, 10)}",
|
||||||
id = "room-${id}"
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
)
|
)
|
||||||
|
|
||||||
|
stream?.next()?.let { res ->
|
||||||
|
if (res.event != null) {
|
||||||
|
// Connect to the msg relays
|
||||||
|
connectMsgRelays(res.event!!)
|
||||||
|
// Mark the member as connected
|
||||||
|
results[member]?.add(res.relayUrl)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to connect to chat room: ${e.message}", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun chatRoomAuth(event: Event) {
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
try {
|
try {
|
||||||
val urls = nip17ExtractRelayList(event);
|
val urls = nip17ExtractRelayList(event);
|
||||||
for (url in urls) {
|
for (url in urls) {
|
||||||
|
if (client?.relay(url) == null) {
|
||||||
client?.addRelay(url)
|
client?.addRelay(url)
|
||||||
client?.connectRelay(url)
|
client?.connectRelay(url)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to authenticate chat room: ${e.message}", e)
|
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -373,16 +373,15 @@ class NostrViewModel(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId)
|
val room = getChatRoom(roomId)
|
||||||
val members = room.members
|
val members = room.members
|
||||||
|
|
||||||
nostr.chatRoomConnect(roomId, members.toList())
|
return runCatching {
|
||||||
} catch (e: Exception) {
|
nostr.chatRoomConnect(members.toList())
|
||||||
|
}.getOrElse { e ->
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
members.associateWith { emptyList<RelayUrl>() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user