chore: merge the develop branch into master #1
@@ -25,7 +25,7 @@ kotlin {
|
||||
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.1")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.2")
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
|
||||
@@ -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,800L440,313L216,537L160,480L480,160L800,480L744,537L520,313L520,800L440,800Z" />
|
||||
</vector>
|
||||
@@ -2,12 +2,15 @@ package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -15,8 +18,10 @@ 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.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -31,6 +36,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -39,16 +45,19 @@ 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.text.style.TextAlign
|
||||
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 coop.composeapp.generated.resources.ic_send
|
||||
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.humanReadable
|
||||
import su.reya.coop.roomId
|
||||
import su.reya.coop.shared.displayNameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
|
||||
@@ -59,18 +68,41 @@ fun ChatScreen(
|
||||
) {
|
||||
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) }
|
||||
|
||||
val messages = remember { mutableStateListOf<Event>() }
|
||||
|
||||
fun setLoading(value: Boolean) {
|
||||
loading = value
|
||||
}
|
||||
|
||||
LaunchedEffect(id) {
|
||||
loading = true
|
||||
messages = viewModel.getChatRoomMessages(id)
|
||||
loading = false
|
||||
// Start loading spinner
|
||||
setLoading(true)
|
||||
|
||||
// Get messages
|
||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
||||
messages.clear()
|
||||
messages.addAll(initialMessages)
|
||||
|
||||
// Get msg relays for each member
|
||||
viewModel.chatRoomConnect(id)
|
||||
|
||||
// Stop loading spinner
|
||||
setLoading(false)
|
||||
|
||||
// Handle new messages
|
||||
viewModel.newEvents.collect { event ->
|
||||
if (event.roomId() == id) {
|
||||
messages.add(0, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -153,7 +185,7 @@ fun ChatScreen(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
// TODO: Implement send logic
|
||||
viewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
}
|
||||
)
|
||||
@@ -190,10 +222,13 @@ fun ChatMessage(
|
||||
.padding(vertical = 4.dp),
|
||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
||||
) {
|
||||
Text(
|
||||
text = event.createdAt().humanReadable(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = if (isMine) TextAlign.End else TextAlign.Start,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Surface(
|
||||
@@ -218,24 +253,41 @@ fun ChatInput(
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.imePadding(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
placeholder = { Text("Message") },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = CircleShape,
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_send),
|
||||
contentDescription = "Send"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ kotlin {
|
||||
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.1")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.2")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
|
||||
@@ -2,6 +2,10 @@ package su.reya.coop
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Client
|
||||
@@ -32,10 +36,12 @@ import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SleepWhenIdle
|
||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.TagKind
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.UnwrappedGift
|
||||
import rust.nostr.sdk.initLogger
|
||||
import rust.nostr.sdk.makePrivateMsgAsync
|
||||
import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import kotlin.time.Duration
|
||||
|
||||
@@ -46,6 +52,8 @@ class Nostr {
|
||||
private set
|
||||
var deviceSigner: AsyncNostrSigner? = null
|
||||
private set
|
||||
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
|
||||
private set
|
||||
var contactList: List<PublicKey> = emptyList()
|
||||
private set
|
||||
|
||||
@@ -94,7 +102,8 @@ class Nostr {
|
||||
client?.shutdown()
|
||||
}
|
||||
|
||||
fun exit() {
|
||||
suspend fun exit() {
|
||||
signer.switch(Keys.generate())
|
||||
deviceSigner = null
|
||||
contactList = emptyList()
|
||||
}
|
||||
@@ -164,17 +173,23 @@ class Nostr {
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.manual(target),
|
||||
id = "user-messages"
|
||||
id = "messages"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleNotifications(onMetadataUpdate: (PublicKey, Metadata) -> Unit) {
|
||||
suspend fun handleNotifications(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
onNewMessage: (Event) -> Unit
|
||||
) = coroutineScope {
|
||||
val now = Timestamp.now()
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
val notifications = client?.notifications() ?: return
|
||||
val notifications = client?.notifications() ?: return@coroutineScope
|
||||
|
||||
var eoseTrackerJob: Job? = null
|
||||
|
||||
while (true) {
|
||||
val notification = notifications.next() ?: continue
|
||||
@@ -204,12 +219,30 @@ class Nostr {
|
||||
if (isSignedByUser(event = event)) {
|
||||
getUserMessages(msgRelayList = event)
|
||||
}
|
||||
// Cache the relay list for future use
|
||||
setMsgRelay(pubkey = event.author(), event = event)
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||
try {
|
||||
val rumor = extractRumor(event)
|
||||
// TODO: Handle rumor
|
||||
|
||||
// Logic to notify UI after processing
|
||||
// Cancel previous tracker if it exists
|
||||
eoseTrackerJob?.cancel()
|
||||
// Start a new tracker
|
||||
eoseTrackerJob = launch {
|
||||
delay(10000) // Wait for 10 seconds
|
||||
onEose()
|
||||
}
|
||||
|
||||
// Handle new message
|
||||
rumor?.createdAt()?.asSecs()?.let {
|
||||
if (it >= now.asSecs()) {
|
||||
// TODO: only send unsigned event
|
||||
onNewMessage(rumor.signWithKeys(Keys.generate()))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Failed to extract rumor: $e")
|
||||
}
|
||||
@@ -218,7 +251,10 @@ class Nostr {
|
||||
|
||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||
val subscriptionId = message.subscriptionId
|
||||
// TODO: Handle end of stored events
|
||||
|
||||
if (subscriptionId == "messages") {
|
||||
onEose()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
@@ -238,6 +274,11 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMsgRelay(pubkey: PublicKey, event: Event) {
|
||||
val relays = nip17ExtractRelayList(event)
|
||||
msgRelayList = msgRelayList + (pubkey to relays)
|
||||
}
|
||||
|
||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||
try {
|
||||
val filter = Filter().identifier(giftId.toBech32())
|
||||
@@ -245,16 +286,18 @@ class Nostr {
|
||||
|
||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get cached rumor: ${e.message}")
|
||||
return null
|
||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||
if (rumor.id() == null) return
|
||||
|
||||
try {
|
||||
val rngKeys = Keys.generate()
|
||||
|
||||
// Ensure the rumor ID is set
|
||||
val rumor = rumor.ensureId()
|
||||
|
||||
// Construct a reference event
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
||||
val tags = listOf(Tag.identifier(giftId.toBech32()), Tag.event(rumor.id()!!))
|
||||
val event = EventBuilder(kind, rumor.asJson()).tags(tags).signWithKeys(rngKeys)
|
||||
@@ -444,7 +487,10 @@ class Nostr {
|
||||
val room = Room.new(rumor = event, userPubkey = userPubkey)
|
||||
|
||||
// Check if the room already exists
|
||||
if (rooms.contains(room)) return@forEach
|
||||
if (rooms.contains(room)) {
|
||||
room.setCreatedAt(room.createdAt)
|
||||
room.setLastMessage(room.lastMessage)
|
||||
}
|
||||
|
||||
val filter =
|
||||
Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList());
|
||||
@@ -473,18 +519,95 @@ class Nostr {
|
||||
suspend fun getChatRoomMessages(members: List<PublicKey>): List<Event> {
|
||||
try {
|
||||
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
||||
val sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members)
|
||||
val recvFilter = Filter().kind(kind).pubkey(userPubkey).authors(members)
|
||||
|
||||
val sendFilter = Filter().kind(kind).author(userPubkey).pubkeys(members)
|
||||
val sendEvents = client?.database()?.query(sendFilter)
|
||||
|
||||
val recvFilter = Filter().kind(kind).authors(members).pubkey(userPubkey)
|
||||
val recvEvents = client?.database()?.query(recvFilter)
|
||||
val events = sendEvents?.merge(recvEvents!!)?.toVec()
|
||||
|
||||
// Merge the events
|
||||
val events = sendEvents
|
||||
?.merge(recvEvents!!)
|
||||
?.toVec()
|
||||
?.sortedByDescending { it.createdAt().asSecs() }
|
||||
|
||||
return events ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||
try {
|
||||
members.forEach { member ->
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.auto(listOf(filter)),
|
||||
closeOn = opts
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to connect to chat room: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessage(
|
||||
to: List<PublicKey>,
|
||||
content: String,
|
||||
subject: String? = null,
|
||||
replies: List<EventId> = emptyList()
|
||||
) {
|
||||
try {
|
||||
val currentUser =
|
||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
val tags = mutableListOf<Tag>()
|
||||
|
||||
// Add a subject tag if provided
|
||||
if (subject != null) {
|
||||
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
|
||||
}
|
||||
|
||||
// Add event tags for replies
|
||||
if (replies.isNotEmpty()) {
|
||||
replies.forEach { replyId ->
|
||||
tags.add(Tag.event(replyId))
|
||||
}
|
||||
}
|
||||
|
||||
// Add public key tags for each recipient
|
||||
to.forEach { pubkey ->
|
||||
if (pubkey != currentUser) {
|
||||
tags.add(Tag.publicKey(pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
for (receiver in to.plus(currentUser)) {
|
||||
// Construct the gift wrap event
|
||||
val event = makePrivateMsgAsync(
|
||||
signer = signer,
|
||||
receiver = receiver,
|
||||
message = content,
|
||||
rumorExtraTags = tags
|
||||
)
|
||||
|
||||
println("Sending message to: ${receiver.toBech32()}")
|
||||
|
||||
// Send the event to receiver's NIP-17 relays
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.toNip17(),
|
||||
ackPolicy = AckPolicy.none(),
|
||||
authenticationTimeout = Duration.parse("2s")
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,10 @@ import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,6 +18,7 @@ import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
@@ -39,6 +42,9 @@ class NostrViewModel(
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<Event>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
@@ -123,9 +129,19 @@ class NostrViewModel(
|
||||
|
||||
fun startNotificationHandler() {
|
||||
viewModelScope.launch {
|
||||
nostr.handleNotifications { pubkey, metadata ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
},
|
||||
onEose = {
|
||||
getChatRooms()
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
viewModelScope.launch {
|
||||
_newEvents.emit(event)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +315,35 @@ class NostrViewModel(
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId)
|
||||
val members = room.members
|
||||
|
||||
nostr.chatRoomConnect(members.toList())
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId)
|
||||
nostr.sendMessage(
|
||||
to = room.members.toList(),
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Ensure all relays are disconnect
|
||||
|
||||
@@ -77,6 +77,10 @@ data class Room(
|
||||
return this.copy(subject = subject)
|
||||
}
|
||||
|
||||
fun setLastMessage(message: String?): Room {
|
||||
return this.copy(lastMessage = message)
|
||||
}
|
||||
|
||||
fun isGroup(): Boolean {
|
||||
return members.size > 1
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user