2 Commits

Author SHA1 Message Date
47196dbea4 chore: bump version 2026-07-15 19:44:52 +07:00
30191170f1 chore: fix some crash and performance issues (#45)
Reviewed-on: #45
2026-07-15 12:44:28 +00:00
14 changed files with 139 additions and 136 deletions

View File

@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt() minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1 versionCode = 1
versionName = "0.2.4" versionName = "0.2.5"
} }
packaging { packaging {
resources { resources {

View File

@@ -6,14 +6,11 @@ import android.os.Build
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme import androidx.compose.material3.MotionScheme
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
@@ -27,8 +24,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -235,34 +230,26 @@ fun App(
NewIdentityScreen(accountViewModel) NewIdentityScreen(accountViewModel)
} }
entry<Screen.Chat> { key -> entry<Screen.Chat> { key ->
val initialRoom = remember(key.id) { chatRepository.getChatRoom(key.id) } val factory = remember(key) {
object : ViewModelProvider.Factory {
if (initialRoom != null) { override fun <T : ViewModel> create(modelClass: Class<T>): T {
val factory = remember(initialRoom) { @Suppress("UNCHECKED_CAST")
object : ViewModelProvider.Factory { return ChatScreenViewModel(
override fun <T : ViewModel> create(modelClass: Class<T>): T { key.id,
return ChatScreenViewModel( key.screening,
initialRoom, accountRepository,
key.screening, chatRepository
accountRepository, ) as T
chatRepository
) as T
}
} }
} }
ChatScreen(
viewModel<ChatScreenViewModel>(
key = key.id.toString(),
factory = factory
),
accountViewModel
)
} else {
// Handle the rare case where the room isn't in DB (e.g., invalid deep link)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Room not found")
}
} }
ChatScreen(
viewModel<ChatScreenViewModel>(
key = key.id.toString(),
factory = factory
),
accountViewModel
)
} }
entry<Screen.NewChat> { entry<Screen.NewChat> {
NewChatScreen(accountViewModel, chatViewModel) NewChatScreen(accountViewModel, chatViewModel)

View File

@@ -98,9 +98,9 @@ import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.ago import su.reya.coop.ago
import su.reya.coop.flow
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel import su.reya.coop.viewmodel.ChatViewModel
@@ -620,9 +620,9 @@ fun NewRequests(requests: List<Room>) {
val firstRoom = requests.getOrNull(0) val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1) val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).flow(profileCache) val firstRoomState by (firstRoom as Room).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState()) .collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).flow(profileCache) val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache)
.collectAsStateWithLifecycle(RoomUiState()) .collectAsStateWithLifecycle(RoomUiState())
val supportingText = when { val supportingText = when {
@@ -695,7 +695,7 @@ fun NewRequests(requests: List<Room>) {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val profileCache = LocalProfileCache.current val profileCache = LocalProfileCache.current
val roomState by room.flow(profileCache).collectAsStateWithLifecycle(RoomUiState()) val roomState by room.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState())
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),

View File

@@ -397,7 +397,7 @@ fun ContactListItem(
supportingContent = { Text(text = pubkey.short()) }, supportingContent = { Text(text = pubkey.short()) },
content = { content = {
Text( Text(
text = profile?.name ?: "", text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -94,11 +94,12 @@ import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomUiState import su.reya.coop.RoomUiState
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.flow
import su.reya.coop.formatAsGroup import su.reya.coop.formatAsGroup
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel import su.reya.coop.viewmodel.ChatScreenViewModel
@@ -117,11 +118,25 @@ fun ChatScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val id = viewModel.id
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle() val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
val pubkey = currentUser?.publicKey val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
val room by viewModel.room.collectAsStateWithLifecycle() // Show empty screen
val roomState by room.flow(profileCache, pubkey).collectAsStateWithLifecycle(RoomUiState()) if (room == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
}
return
}
val loading = viewModel.loading val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages val newOtherMessages = viewModel.newOtherMessages
@@ -131,6 +146,9 @@ fun ChatScreen(
val groupedMessages = val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) } var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
var replyingTo by remember { mutableStateOf<MessageModel?>(null) } var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
@@ -175,7 +193,9 @@ fun ChatScreen(
} }
} }
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier.fillMaxSize()
) {
Scaffold( Scaffold(
modifier = Modifier.blur(blurAmount), modifier = Modifier.blur(blurAmount),
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime), contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
@@ -187,7 +207,7 @@ fun ChatScreen(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { modifier = Modifier.clickable {
room.members.firstOrNull()?.let { pubkey -> room?.members?.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32())) navigator.navigate(Screen.Profile(pubkey.toBech32()))
} }
} }
@@ -245,7 +265,7 @@ fun ChatScreen(
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (requireScreening) { if (requireScreening) {
ScreenerCard(accountViewModel, room) room?.let { ScreenerCard(accountViewModel, it) }
} }
when (messages.isNotEmpty()) { when (messages.isNotEmpty()) {
@@ -263,7 +283,8 @@ fun ChatScreen(
items = messagesInGroup, items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! } key = { it.ensureId().id()?.toHex()!! }
) { event -> ) { event ->
val model = rememberMessageModel(event, pubkey) val model =
rememberMessageModel(event, currentUser?.publicKey)
val replyPreview = val replyPreview =
remember(model.replyEventIds, messages.size) { remember(model.replyEventIds, messages.size) {
@@ -277,9 +298,7 @@ fun ChatScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { replyPreview?.let { ReplyPreview(it, model.isMine) }
ReplyPreview(it, model.isMine)
}
ChatMessage( ChatMessage(
model = model, model = model,
modifier = Modifier.graphicsLayer { modifier = Modifier.graphicsLayer {

View File

@@ -71,7 +71,7 @@ data class RoomUiState(
val isGroup: Boolean = false val isGroup: Boolean = false
) )
fun Room.flow( fun Room.uiStateFlow(
profileCache: ProfileCache, profileCache: ProfileCache,
currentUser: PublicKey? = null currentUser: PublicKey? = null
): Flow<RoomUiState> { ): Flow<RoomUiState> {
@@ -112,13 +112,8 @@ fun UnsignedEvent.roomId(): Long {
val pubkeys: MutableList<PublicKey> = mutableListOf() val pubkeys: MutableList<PublicKey> = mutableListOf()
pubkeys.add(this.author()) pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys()) pubkeys.addAll(this.tags().publicKeys())
// Sort and hash the list of public keys return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
} }
fun Timestamp.formatAsTime(): String { fun Timestamp.formatAsTime(): String {

View File

@@ -39,12 +39,11 @@ class MessageManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer private val signer: UniversalSigner get() = nostr.signer
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private val _messageSyncState = MutableStateFlow(MessageSyncState()) private val _messageSyncState = MutableStateFlow(MessageSyncState())
val messageSyncState = _messageSyncState.asStateFlow() val messageSyncState = _messageSyncState.asStateFlow()
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) { fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
_messageSyncState.update(update) _messageSyncState.update(update)
} }
@@ -73,6 +72,8 @@ class MessageManager(private val nostr: Nostr) {
target = ReqTarget.manual(target), target = ReqTarget.manual(target),
id = "gift-wraps" id = "gift-wraps"
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e) throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
} }
@@ -225,6 +226,8 @@ class MessageManager(private val nostr: Nostr) {
// Filter out events without public keys (receivers) // Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() } ?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: CancellationException) {
throw e
} 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)
} }
@@ -248,6 +251,8 @@ class MessageManager(private val nostr: Nostr) {
connectMsgRelays(event) connectMsgRelays(event)
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e) throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
} }
@@ -327,9 +332,6 @@ class MessageManager(private val nostr: Nostr) {
) )
if (output != null) { if (output != null) {
// Keep track of sent events
sentEvents[output.id] = emptyList()
// Keep track of rumor IDs // Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id rumorMap[id] = output.id
@@ -340,6 +342,8 @@ class MessageManager(private val nostr: Nostr) {
} }
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e) throw IllegalStateException("Failed to send message: ${e.message}", e)
} }

View File

@@ -9,9 +9,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientBuilder
@@ -108,14 +108,6 @@ class Nostr(
relays.connectBootstrapRelays() relays.connectBootstrapRelays()
} }
suspend fun reconnect() {
relays.reconnect()
}
suspend fun disconnect() {
relays.disconnect()
}
suspend fun prune() { suspend fun prune() {
try { try {
client?.database()?.wipe() client?.database()?.wipe()
@@ -182,8 +174,6 @@ class Nostr(
when (notification) { when (notification) {
is ClientNotification.Message -> { is ClientNotification.Message -> {
val relayUrl = notification.relayUrl
when (val message = notification.message.asEnum()) { when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> { is RelayMessageEnum.EventMsg -> {
val event = message.event val event = message.event
@@ -237,14 +227,6 @@ class Nostr(
} }
} }
is RelayMessageEnum.Ok -> {
if (messages.sentEvents.containsKey(message.eventId)) {
val currentRelays =
messages.sentEvents[message.eventId] ?: emptyList()
messages.sentEvents[message.eventId] = currentRelays + relayUrl
}
}
else -> { else -> {
/* Ignore other message types */ /* Ignore other message types */
} }

View File

@@ -27,6 +27,7 @@ import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration import kotlin.time.Duration
class ProfileManager(private val nostr: Nostr) { class ProfileManager(private val nostr: Nostr) {
@@ -80,6 +81,8 @@ class ProfileManager(private val nostr: Nostr) {
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "user-metadata", closeOn = opts) client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e) throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
} }
@@ -92,6 +95,8 @@ class ProfileManager(private val nostr: Nostr) {
val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) } val relays = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays) client?.sync(filter, relays)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to sync mutual contacts: ${e.message}") println("Failed to sync mutual contacts: ${e.message}")
} }
@@ -174,6 +179,8 @@ class ProfileManager(private val nostr: Nostr) {
) )
return newMetadata return newMetadata
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e) throw IllegalStateException("Failed to update identity: ${e.message}", e)
} }
@@ -186,6 +193,8 @@ class ProfileManager(private val nostr: Nostr) {
val event = client?.database()?.query(filter)?.first() ?: return null val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content()) Metadata.fromJson(event.content())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}") println("Failed to get latest metadata: ${e.message}")
null null
@@ -208,6 +217,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return results return results
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to get all cache metadata: ${e.message}") println("Failed to get all cache metadata: ${e.message}")
return emptyMap() return emptyMap()
@@ -232,6 +243,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts) client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
} }
@@ -247,6 +260,8 @@ class ProfileManager(private val nostr: Nostr) {
target = SendEventTarget.broadcast(), target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(), ackPolicy = AckPolicy.none(),
) )
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to set contact list: ${e.message}", e) throw IllegalStateException("Failed to set contact list: ${e.message}", e)
} }
@@ -258,6 +273,8 @@ class ProfileManager(private val nostr: Nostr) {
val bodyString: String = response.body() val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString) return Nip05Profile.fromJson(address, bodyString)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
} }
@@ -269,6 +286,8 @@ class ProfileManager(private val nostr: Nostr) {
val profile = profileFromAddress(httpClient, address) val profile = profileFromAddress(httpClient, address)
return profile.publicKey() return profile.publicKey()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e) throw IllegalStateException("Failed to search address: ${e.message}", e)
} }
@@ -304,6 +323,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return results return results
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e) throw IllegalStateException("Failed to search nostr: ${e.message}", e)
} }
@@ -323,6 +344,8 @@ class ProfileManager(private val nostr: Nostr) {
) )
return events?.first()?.createdAt() return events?.first()?.createdAt()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get latest activity: ${e.message}", e) throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
} }
@@ -340,6 +363,8 @@ class ProfileManager(private val nostr: Nostr) {
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf() val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
return pubkeys.contains(pubkey) return pubkeys.contains(pubkey)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
} }
@@ -361,6 +386,8 @@ class ProfileManager(private val nostr: Nostr) {
} }
return contacts.toSet() return contacts.toSet()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e) throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
} }

View File

@@ -130,7 +130,7 @@ class RelayManager(private val nostr: Nostr) {
} }
} }
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) { suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
try { try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer) val event = EventBuilder.relayList(relays).finalizeAsync(signer)
@@ -143,7 +143,7 @@ class RelayManager(private val nostr: Nostr) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e) throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
} }
} }
suspend fun setMsgRelays(urls: List<RelayUrl>) { suspend fun setMsgRelays(urls: List<RelayUrl>) {
try { try {
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer) val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)

View File

@@ -511,7 +511,7 @@ class AccountRepository(
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays) nostr.relays.setRelayList(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -525,7 +525,7 @@ class AccountRepository(
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays) nostr.relays.setRelayList(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -539,7 +539,7 @@ class AccountRepository(
val relays = currentUserRelayListInternal().toMutableMap() val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl) relays.remove(relayUrl)
nostr.relays.setRelaylist(relays) nostr.relays.setRelayList(relays)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }

View File

@@ -51,13 +51,16 @@ class ChatRepository(
) )
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
val chatRooms = state.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } } val chatRooms = state
.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing } val isSyncing = nostr.messages.messageSyncState
.map { it.isSyncing }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false) .stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap } val isPartialProcessedGiftWrap = state
.map { it.isPartialProcessedGiftWrap }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false) .stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init { init {
@@ -82,18 +85,7 @@ class ChatRepository(
// Observe new messages // Observe new messages
launch { launch {
nostr.newEvents.collect { event -> nostr.newEvents.collect { event ->
val roomId = event.roomId() updateRoomState(event)
val existingRoom = _state.value.rooms[roomId]
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update { it.copy(rooms = it.rooms + (newRoom.id to newRoom)) }
} else {
updateRoomList(roomId, event)
}
_newEvents.tryEmit(event)
} }
} }
} }
@@ -194,9 +186,10 @@ class ChatRepository(
content = message, content = message,
subject = room.subject, subject = room.subject,
replies = replies, replies = replies,
onRumorCreated = { event -> onRumorCreated = {
updateRoomList(roomId, event) scope.launch(defaultDispatcher) {
scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) } updateRoomState(it, roomId)
}
}, },
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -223,26 +216,32 @@ class ChatRepository(
} }
} }
fun isMessageSent(id: EventId): Boolean { private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val giftWrapId = nostr.messages.rumorMap[id] val currentUser = nostr.signer.getPublicKeyAsync() ?: return
if (giftWrapId != null) {
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState -> _state.update { currentState ->
val room = currentState.rooms[roomId] ?: return@update currentState val rooms = currentState.rooms.toMutableMap()
val updatedRoom = room.copy( val existingRoom = rooms[roomId]
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt() if (existingRoom == null) {
) // New room discovery
currentState.copy(rooms = currentState.rooms + (roomId to updatedRoom)) val newRoom = Room.new(event, currentUser, roomId)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Only update preview if message is newer (handles sync/late arrivals)
rooms[roomId] = existingRoom.copy(
lastMessage = event.content(),
createdAt = event.createdAt()
)
} else {
// Don't update the room list state for older messages
return@update currentState
}
currentState.copy(rooms = rooms)
} }
// Notify subscribers about the new event (for the active chat screen)
_newEvents.tryEmit(event)
} }
fun resetInternalState() { fun resetInternalState() {

View File

@@ -7,40 +7,30 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.repository.AccountRepository import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository import su.reya.coop.repository.ChatRepository
import su.reya.coop.roomId import su.reya.coop.roomId
class ChatScreenViewModel( class ChatScreenViewModel(
initialRoom: Room, val id: Long,
screening: Boolean, screening: Boolean,
accountRepository: AccountRepository, accountRepository: AccountRepository,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
) : ViewModel(), ErrorHost by chatRepository { ) : ViewModel(), ErrorHost by chatRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
var loading by mutableStateOf(true) var loading by mutableStateOf(true)
var newOtherMessages by mutableIntStateOf(0) var newOtherMessages by mutableIntStateOf(0)
var requireScreening by mutableStateOf(screening) var requireScreening by mutableStateOf(screening)
val messages = mutableStateListOf<UnsignedEvent>() val messages = mutableStateListOf<UnsignedEvent>()
val currentUser = accountRepository.currentUserProfile
val id = initialRoom.id
val room: StateFlow<Room> = chatRepository.chatRooms
.map { rooms -> rooms.find { it.id == id } ?: initialRoom }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = initialRoom
)
init { init {
loadMessages() loadMessages()
connect() connect()

View File

@@ -18,7 +18,7 @@ import kotlin.time.Duration.Companion.milliseconds
class ProfileCache( class ProfileCache(
private val nostr: Nostr, private val nostr: Nostr,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() { ) : ErrorHost by createErrorHost() {
private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher) private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap()) private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())