2 Commits

Author SHA1 Message Date
43618533a5 improve the profile cache 2026-07-15 08:18:05 +07:00
9c9d9ae882 clean up chat screen 2026-07-15 07:32:39 +07:00
14 changed files with 136 additions and 139 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.5" versionName = "0.2.4"
} }
packaging { packaging {
resources { resources {

View File

@@ -6,11 +6,14 @@ 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
@@ -24,6 +27,8 @@ 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
@@ -230,12 +235,14 @@ fun App(
NewIdentityScreen(accountViewModel) NewIdentityScreen(accountViewModel)
} }
entry<Screen.Chat> { key -> entry<Screen.Chat> { key ->
val factory = remember(key) { val initialRoom = remember(key.id) { chatRepository.getChatRoom(key.id) }
if (initialRoom != null) {
val factory = remember(initialRoom) {
object : ViewModelProvider.Factory { object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatScreenViewModel( return ChatScreenViewModel(
key.id, initialRoom,
key.screening, key.screening,
accountRepository, accountRepository,
chatRepository chatRepository
@@ -250,6 +257,12 @@ fun App(
), ),
accountViewModel 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")
}
}
} }
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).uiStateFlow(profileCache) val firstRoomState by (firstRoom as Room).flow(profileCache)
.collectAsStateWithLifecycle(RoomUiState()) .collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by (secondRoom ?: firstRoom).uiStateFlow(profileCache) val secondRoomState by (secondRoom ?: firstRoom).flow(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.uiStateFlow(profileCache).collectAsStateWithLifecycle(RoomUiState()) val roomState by room.flow(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 ?: "Unknown", text = profile?.name ?: "",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }

View File

@@ -94,12 +94,11 @@ 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
@@ -118,25 +117,11 @@ 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 chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val pubkey = currentUser?.publicKey
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen val room by viewModel.room.collectAsStateWithLifecycle()
if (room == null) { val roomState by room.flow(profileCache, pubkey).collectAsStateWithLifecycle(RoomUiState())
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
@@ -146,9 +131,6 @@ 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) }
@@ -193,9 +175,7 @@ fun ChatScreen(
} }
} }
Box( Box(modifier = Modifier.fillMaxSize()) {
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),
@@ -207,7 +187,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()))
} }
} }
@@ -265,7 +245,7 @@ fun ChatScreen(
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (requireScreening) { if (requireScreening) {
room?.let { ScreenerCard(accountViewModel, it) } ScreenerCard(accountViewModel, room)
} }
when (messages.isNotEmpty()) { when (messages.isNotEmpty()) {
@@ -283,8 +263,7 @@ fun ChatScreen(
items = messagesInGroup, items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! } key = { it.ensureId().id()?.toHex()!! }
) { event -> ) { event ->
val model = val model = rememberMessageModel(event, pubkey)
rememberMessageModel(event, currentUser?.publicKey)
val replyPreview = val replyPreview =
remember(model.replyEventIds, messages.size) { remember(model.replyEventIds, messages.size) {
@@ -298,7 +277,9 @@ fun ChatScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
replyPreview?.let { ReplyPreview(it, model.isMine) } replyPreview?.let {
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.uiStateFlow( fun Room.flow(
profileCache: ProfileCache, profileCache: ProfileCache,
currentUser: PublicKey? = null currentUser: PublicKey? = null
): Flow<RoomUiState> { ): Flow<RoomUiState> {
@@ -113,7 +113,12 @@ fun UnsignedEvent.roomId(): Long {
pubkeys.add(this.author()) pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys()) pubkeys.addAll(this.tags().publicKeys())
return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong() // Sort and hash the list of public keys
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
} }
fun Timestamp.formatAsTime(): String { fun Timestamp.formatAsTime(): String {

View File

@@ -39,11 +39,12 @@ 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)
} }
@@ -72,8 +73,6 @@ 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)
} }
@@ -226,8 +225,6 @@ 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)
} }
@@ -251,8 +248,6 @@ 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)
} }
@@ -332,6 +327,9 @@ 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
@@ -342,8 +340,6 @@ 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,6 +108,14 @@ 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()
@@ -174,6 +182,8 @@ 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
@@ -227,6 +237,14 @@ 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,7 +27,6 @@ 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) {
@@ -81,8 +80,6 @@ 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)
} }
@@ -95,8 +92,6 @@ 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}")
} }
@@ -179,8 +174,6 @@ 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)
} }
@@ -193,8 +186,6 @@ 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
@@ -217,8 +208,6 @@ 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()
@@ -243,8 +232,6 @@ 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)
} }
@@ -260,8 +247,6 @@ 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)
} }
@@ -273,8 +258,6 @@ 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)
} }
@@ -286,8 +269,6 @@ 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)
} }
@@ -323,8 +304,6 @@ 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)
} }
@@ -344,8 +323,6 @@ 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)
} }
@@ -363,8 +340,6 @@ 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)
} }
@@ -386,8 +361,6 @@ 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)

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

View File

@@ -7,30 +7,40 @@ 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(
val id: Long, initialRoom: Room,
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,
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, private val 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())