feat: support grouped new chat requests (#23)

Reviewed-on: #23
This commit was merged in pull request #23.
This commit is contained in:
2026-06-18 00:34:23 +00:00
parent ea90a43909
commit 91e4e3b43d
9 changed files with 351 additions and 51 deletions

View File

@@ -24,7 +24,7 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.7") implementation("su.reya:nostr-sdk-kmp:0.3")
implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")

View File

@@ -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="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>

View File

@@ -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="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
</vector>

View File

@@ -44,6 +44,7 @@ import su.reya.coop.screens.NewIdentityScreen
import su.reya.coop.screens.OnboardingScreen import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.UpdateProfileScreen
@@ -164,6 +165,9 @@ fun App(viewModel: NostrViewModel) {
entry<Screen.Home> { entry<Screen.Home> {
HomeScreen() HomeScreen()
} }
entry<Screen.RequestList> {
RequestListScreen()
}
entry<Screen.Onboarding> { entry<Screen.Onboarding> {
OnboardingScreen() OnboardingScreen()
} }

View File

@@ -23,6 +23,9 @@ sealed interface Screen : NavKey {
@Serializable @Serializable
data object Home : Screen data object Home : Screen
@Serializable
data object RequestList : Screen
@Serializable @Serializable
data class Chat(val id: Long) : Screen data class Chat(val id: Long) : Screen

View File

@@ -76,6 +76,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -84,7 +85,9 @@ import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_close import coop.composeapp.generated.resources.ic_close
import coop.composeapp.generated.resources.ic_new_chat import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_request
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -93,6 +96,7 @@ import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.ago import su.reya.coop.ago
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
@@ -111,8 +115,14 @@ fun HomeScreen() {
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return val currentUserProfile = viewModel.getMetadata(currentUser)
val userProfile by currentUserProfile.collectAsStateWithLifecycle() val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
@@ -120,11 +130,6 @@ fun HomeScreen() {
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
@@ -140,6 +145,11 @@ fun HomeScreen() {
// State will be updated by LifecycleResumeEffect // State will be updated by LifecycleResumeEffect
} }
// Partition chat rooms into requests and ongoing
val (requests, ongoing) = remember(chatRooms) {
chatRooms.partition { it.kind == RoomKind.Request }
}
LifecycleResumeEffect(context) { LifecycleResumeEffect(context) {
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { } onPauseOrDispose { }
@@ -350,7 +360,11 @@ fun HomeScreen() {
state = listState, state = listState,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items(chatRooms.toList(), key = { it.id }) { room -> if (requests.isNotEmpty()) {
item { NewRequests(requests) }
}
items(ongoing, key = { it.id }) { room ->
ChatRoom( ChatRoom(
room = room, room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) } onClick = { navigator.navigate(Screen.Chat(room.id)) }
@@ -603,6 +617,89 @@ fun HomeScreen() {
} }
} }
@Composable
fun NewRequests(requests: List<Room>) {
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val total = requests.size
val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1)
val firstName by remember(firstRoom) {
firstRoom?.displayNameFlow(viewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("Loading...")
val secondName by remember(secondRoom) {
secondRoom?.displayNameFlow(viewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("")
val supportingText = when {
total == 1 && firstRoom != null -> {
val message = firstRoom.lastMessage ?: ""
"$firstName: $message"
}
total == 2 -> {
"$firstName and $secondName"
}
total > 2 -> {
val othersCount = total - 2
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
"$firstName, $secondName and $othersText"
}
else -> ""
}
ListItem(
modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList)
},
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
}
}
},
headlineContent = {
Text(
text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
supportingContent = {
if (supportingText.isNotEmpty()) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
@@ -636,7 +733,8 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
if (!room.lastMessage.isNullOrBlank()) { if (!room.lastMessage.isNullOrBlank()) {
Text( Text(
text = room.lastMessage!!, text = room.lastMessage!!,
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis
) )
} }
}, },

View File

@@ -0,0 +1,155 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RequestListScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) }
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
// Get all request rooms
val requests = remember(chatRooms) {
chatRooms.filter { it.kind == RoomKind.Request }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Text("New Requests", style = MaterialTheme.typography.titleMediumEmphasized)
},
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (requests.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No requests yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "New chat requests will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(requests.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
}
}
}
}
}
}

View File

@@ -33,7 +33,7 @@ kotlin {
implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.7") implementation("su.reya:nostr-sdk-kmp:0.3")
implementation("com.squareup.okio:okio:3.16.2") implementation("com.squareup.okio:okio:3.16.2")
} }
androidMain.dependencies { androidMain.dependencies {

View File

@@ -50,11 +50,11 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.initLogger import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -78,8 +78,6 @@ class Nostr {
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
private set private set
var deviceSigner: AsyncNostrSigner? = null
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()
@@ -310,7 +308,6 @@ class Nostr {
} }
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
val rumor = extractRumor(event) val rumor = extractRumor(event)
// Logic to notify UI after processing // Logic to notify UI after processing
@@ -328,9 +325,6 @@ class Nostr {
onNewMessage(rumor) onNewMessage(rumor)
} }
} }
} catch (e: Exception) {
println("Failed to extract rumor: $e")
}
} }
} }
@@ -372,7 +366,7 @@ class Nostr {
val event = client?.database()?.query(filter)?.first() val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it) } return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) { } catch (e: Throwable) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
} }
} }
@@ -392,7 +386,7 @@ class Nostr {
) )
// Set event kind // Set event kind
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
// Construct event // Construct event
val event = EventBuilder(kind, rumor.asJson()) val event = EventBuilder(kind, rumor.asJson())
@@ -400,36 +394,64 @@ class Nostr {
.finalizeAsync(Keys.generate()) .finalizeAsync(Keys.generate())
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
} catch (e: Exception) { } catch (e: Throwable) {
println("Failed to set cached rumor: ${e.message}") println("Failed to set cached rumor: ${e.message}")
} }
} }
private suspend fun extractRumor(event: Event): UnsignedEvent? { private suspend fun extractRumor(event: Event): UnsignedEvent? {
try { try {
// Gift wrap must have at least one 'p' tag
if (event.tags().publicKeys().isEmpty()) {
println("No recipient tags found.")
return null
}
// Event must be a gift wrap
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
println("Event is not a gift wrap.")
return null
}
// Check if the rumor is already cached // Check if the rumor is already cached
val cachedRumor = getCachedRumor(event.id()) val cachedRumor = getCachedRumor(event.id())
if (cachedRumor != null) return cachedRumor if (cachedRumor != null) return cachedRumor
// Unwrap the gift with current signer // Decrypt the gift wrap event
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) val seal = signer.nip44DecryptAsync(event.author(), event.content())
val rumor = gift.rumor() val sealEvent = Event.fromJson(seal)
// Save the rumor to the database // Verify seal event
setCachedRumor(event.id(), rumor) if (!sealEvent.verify()) {
println("Failed to verify seal event.")
// Return the rumor return null
return rumor
} catch (e: Exception) {
println("Failed to unwrap gift: ${e.message}")
} }
// Decrypt the rumor
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
val unsignedEvent = UnsignedEvent.fromJson(rumor)
// Ensure the rumor author matches the seal
if (unsignedEvent.author() != sealEvent.author()) {
println("Author mismatch.")
return null return null
} }
// Cache the rumor for later use
setCachedRumor(event.id(), unsignedEvent)
return unsignedEvent
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
return null
}
}
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> { private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
// Construct a list of relays // Construct a list of relays
val relayList = mapOf<RelayUrl, RelayMetadata>( val relayList = mapOf(
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
@@ -471,7 +493,7 @@ class Nostr {
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event // Send relay list event
val relayList = getDefaultRelayList() val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys); val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
client?.sendEvent( client?.sendEvent(
event = relayListEvent, event = relayListEvent,
@@ -546,7 +568,7 @@ class Nostr {
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try { return try {
val kind = Kind.fromStd(KindStandard.METADATA); val kind = Kind.fromStd(KindStandard.METADATA)
val filter = Filter().kind(kind).author(pubkey).limit(1u) val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null val event = client?.database()?.query(filter)?.first() ?: return null
@@ -581,7 +603,7 @@ class Nostr {
suspend fun fetchMetadataBatch(keys: List<PublicKey>) { suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
try { try {
val limit = keys.size.toULong() * 4u; val limit = keys.size.toULong() * 4u
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
// Construct a filter for metadata events // Construct a filter for metadata events
@@ -615,7 +637,7 @@ class Nostr {
ackPolicy = AckPolicy.none(), ackPolicy = AckPolicy.none(),
) )
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS); val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u) val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
val target = ReqTarget.auto(listOf(filter)) val target = ReqTarget.auto(listOf(filter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
@@ -722,7 +744,7 @@ class Nostr {
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys) val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
// Determine if it's an ongoing room // Determine if it's an ongoing room
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false val isOngoing = client?.database()?.query(filter)?.isEmpty() ?: false
// Append room to map // Append room to map
roomsMap[newRoom.id] = roomsMap[newRoom.id] =
@@ -781,7 +803,7 @@ class Nostr {
suspend fun connectMsgRelays(event: Event) { suspend fun connectMsgRelays(event: Event) {
try { try {
val urls = nip17ExtractRelayList(event); val urls = nip17ExtractRelayList(event)
for (url in urls) { for (url in urls) {
client?.addRelay(url, RelayCapabilities.gossip()) client?.addRelay(url, RelayCapabilities.gossip())
client?.connectRelay(url) client?.connectRelay(url)