diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7f3b884..2a3dcc3 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) 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-network-okhttp:3.4.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_report.xml b/composeApp/src/androidMain/composeResources/drawable/ic_report.xml new file mode 100644 index 0000000..0b85c04 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_report.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_request.xml b/composeApp/src/androidMain/composeResources/drawable/ic_request.xml new file mode 100644 index 0000000..38af524 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_request.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 1b246d3..ed281a6 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -44,6 +44,7 @@ import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.RelayScreen +import su.reya.coop.screens.RequestListScreen import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.UpdateProfileScreen @@ -164,6 +165,9 @@ fun App(viewModel: NostrViewModel) { entry { HomeScreen() } + entry { + RequestListScreen() + } entry { OnboardingScreen() } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 50aca39..6a53f67 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -23,6 +23,9 @@ sealed interface Screen : NavKey { @Serializable data object Home : Screen + @Serializable + data object RequestList : Screen + @Serializable data class Chat(val id: Long) : Screen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index d29813d..01b8d72 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -76,6 +76,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.app.NotificationManagerCompat 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_new_chat import coop.composeapp.generated.resources.ic_qr +import coop.composeapp.generated.resources.ic_request import coop.composeapp.generated.resources.ic_scanner +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.PublicKey @@ -93,6 +96,7 @@ import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalScanResult import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room +import su.reya.coop.RoomKind import su.reya.coop.Screen import su.reya.coop.ago import su.reya.coop.shared.Avatar @@ -111,8 +115,14 @@ fun HomeScreen() { val clipboardManager = LocalClipboard.current val viewModel = LocalNostrViewModel.current + + val scope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState(true) + val listState = rememberLazyListState() + val pullToRefreshState = rememberPullToRefreshState() + val currentUser = viewModel.currentUser() ?: return - val currentUserProfile = viewModel.getMetadata(currentUser) ?: return + val currentUserProfile = viewModel.getMetadata(currentUser) val userProfile by currentUserProfile.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() @@ -120,11 +130,6 @@ fun HomeScreen() { val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) 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 } } var showBottomSheet by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) } @@ -140,6 +145,11 @@ fun HomeScreen() { // 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) { isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled() onPauseOrDispose { } @@ -350,7 +360,11 @@ fun HomeScreen() { state = listState, modifier = Modifier.fillMaxSize() ) { - items(chatRooms.toList(), key = { it.id }) { room -> + if (requests.isNotEmpty()) { + item { NewRequests(requests) } + } + + items(ongoing, key = { it.id }) { room -> ChatRoom( room = room, onClick = { navigator.navigate(Screen.Chat(room.id)) } @@ -603,6 +617,89 @@ fun HomeScreen() { } } +@Composable +fun NewRequests(requests: List) { + 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) @Composable fun ChatRoom(room: Room, onClick: () -> Unit) { @@ -636,7 +733,8 @@ fun ChatRoom(room: Room, onClick: () -> Unit) { if (!room.lastMessage.isNullOrBlank()) { Text( text = room.lastMessage!!, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis ) } }, diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt new file mode 100644 index 0000000..75ee8da --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/RequestListScreen.kt @@ -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)) } + ) + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f8e160e..ed524c4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -33,7 +33,7 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) 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.7") + implementation("su.reya:nostr-sdk-kmp:0.3") implementation("com.squareup.okio:okio:3.16.2") } androidMain.dependencies { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 6c88227..5baa07e 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -50,11 +50,11 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent -import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.initLogger import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip59MakeGiftWrapAsync +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -78,8 +78,6 @@ class Nostr { private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) private set - var deviceSigner: AsyncNostrSigner? = null - private set var sentEvents: MutableMap> = mutableMapOf() private set var rumorMap: MutableMap = mutableMapOf() @@ -310,26 +308,22 @@ class Nostr { } if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { - try { - val rumor = extractRumor(event) + val rumor = extractRumor(event) - // Logic to notify UI after processing - // Cancel previous tracker if it exists - eoseTrackerJob?.cancel() - // Start a new tracker - eoseTrackerJob = launch { - delay(10000.milliseconds) // Wait for 10 seconds - onSubscriptionClose() - } + // Logic to notify UI after processing + // Cancel previous tracker if it exists + eoseTrackerJob?.cancel() + // Start a new tracker + eoseTrackerJob = launch { + delay(10000.milliseconds) // Wait for 10 seconds + onSubscriptionClose() + } - // Handle new message - rumor?.createdAt()?.asSecs()?.let { - if (it >= now.asSecs()) { - onNewMessage(rumor) - } + // Handle new message + rumor?.createdAt()?.asSecs()?.let { + if (it >= now.asSecs()) { + onNewMessage(rumor) } - } catch (e: Exception) { - println("Failed to extract rumor: $e") } } } @@ -372,7 +366,7 @@ class Nostr { val event = client?.database()?.query(filter)?.first() return event?.content()?.let { UnsignedEvent.fromJson(it) } - } catch (e: Exception) { + } catch (e: Throwable) { throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) } } @@ -392,7 +386,7 @@ class Nostr { ) // Set event kind - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) // Construct event val event = EventBuilder(kind, rumor.asJson()) @@ -400,36 +394,64 @@ class Nostr { .finalizeAsync(Keys.generate()) client?.database()?.saveEvent(event) - } catch (e: Exception) { + } catch (e: Throwable) { println("Failed to set cached rumor: ${e.message}") } } private suspend fun extractRumor(event: Event): UnsignedEvent? { 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 val cachedRumor = getCachedRumor(event.id()) if (cachedRumor != null) return cachedRumor - // Unwrap the gift with current signer - val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) - val rumor = gift.rumor() + // Decrypt the gift wrap event + val seal = signer.nip44DecryptAsync(event.author(), event.content()) + val sealEvent = Event.fromJson(seal) - // Save the rumor to the database - setCachedRumor(event.id(), rumor) + // Verify seal event + if (!sealEvent.verify()) { + println("Failed to verify seal event.") + return null + } - // Return the rumor - 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 + } + + // 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 } - - return null } private suspend fun getDefaultRelayList(): Map { // Construct a list of relays - val relayList = mapOf( + val relayList = mapOf( RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, 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?) { // Send relay list event val relayList = getDefaultRelayList() - val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys); + val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys) client?.sendEvent( event = relayListEvent, @@ -546,7 +568,7 @@ class Nostr { private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { return try { - val kind = Kind.fromStd(KindStandard.METADATA); + val kind = Kind.fromStd(KindStandard.METADATA) val filter = Filter().kind(kind).author(pubkey).limit(1u) val event = client?.database()?.query(filter)?.first() ?: return null @@ -581,7 +603,7 @@ class Nostr { suspend fun fetchMetadataBatch(keys: List) { try { - val limit = keys.size.toULong() * 4u; + val limit = keys.size.toULong() * 4u val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) // Construct a filter for metadata events @@ -615,7 +637,7 @@ class Nostr { 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 target = ReqTarget.auto(listOf(filter)) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) @@ -722,7 +744,7 @@ class Nostr { val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys) // 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 roomsMap[newRoom.id] = @@ -781,7 +803,7 @@ class Nostr { suspend fun connectMsgRelays(event: Event) { try { - val urls = nip17ExtractRelayList(event); + val urls = nip17ExtractRelayList(event) for (url in urls) { client?.addRelay(url, RelayCapabilities.gossip()) client?.connectRelay(url)