chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
49 changed files with 4956 additions and 82 deletions

View File

@@ -1,4 +1,3 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
@@ -6,6 +5,7 @@ plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
kotlin("plugin.serialization") version libs.versions.kotlin.get()
}
kotlin {
@@ -14,11 +14,21 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation("androidx.navigation:navigation-compose:2.8.8")
implementation("androidx.datastore:datastore-preferences:1.2.1")
implementation("androidx.datastore:datastore-preferences-core:1.2.1")
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
implementation("io.github.alexzhirkevich:qrose:1.1.2")
}
commonMain.dependencies {
implementation(libs.compose.runtime)

View File

@@ -1,6 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" />
<uses-feature
android:name="android.hardware.camera.autofocus"
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -9,14 +19,18 @@
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:exported="true"
android:name=".MainActivity">
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".NostrForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,32 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="750dp"
android:height="750dp"
android:viewportWidth="750"
android:viewportHeight="750">
<path
android:pathData="M353.02,681C488.9,681 599.05,570.85 599.05,434.98C599.05,422.63 589.03,412.61 576.68,412.61H129.37C117.01,412.61 107,422.63 107,434.98C107,570.85 217.15,681 353.02,681Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M461.72,151.67L317.71,156.11C317.71,156.11 307.55,142.16 308.32,132.29C309.29,119.83 317.72,109.73 328.88,105.93C328.22,103.04 327.53,100.13 327.77,97.03C329.07,80.3 343.74,67.8 360.51,69.09C370.4,69.85 378.36,75.51 383.33,83.23C389.44,76.36 398.21,72.01 408.1,72.77C423.68,73.97 435.15,86.83 435.69,102.04C437.83,101.74 439.89,101.08 442.14,101.26C458.92,102.55 471.44,117.15 470.14,133.88C469.51,142.02 461.72,151.67 461.72,151.67Z"
android:fillColor="#000000"/>
<path
android:pathData="M150.95,389.09C150.95,253.21 261.1,143.06 396.98,143.06C532.84,143.06 643,253.21 643,389.09C643,401.44 632.98,411.45 620.63,411.45H173.32C160.96,411.45 150.95,401.44 150.95,389.09ZM505.67,259.28C504.32,258.9 502.97,258.71 501.6,258.71C500.23,258.71 498.88,258.9 497.54,259.28C496.19,259.66 494.89,260.23 493.62,260.98C492.36,261.73 491.16,262.65 490.02,263.74C488.88,264.83 487.83,266.07 486.86,267.45C485.89,268.84 485.03,270.35 484.27,271.98C483.51,273.61 482.87,275.33 482.34,277.14C481.82,278.95 481.43,280.82 481.16,282.74C480.89,284.66 480.76,286.6 480.76,288.57C480.76,290.53 480.89,292.47 481.16,294.39C481.43,296.31 481.82,298.17 482.34,299.99C482.87,301.8 483.51,303.52 484.27,305.15C485.03,306.78 485.89,308.29 486.86,309.67C487.83,311.06 488.88,312.3 490.02,313.39C491.16,314.48 492.36,315.4 493.62,316.14C494.89,316.89 496.19,317.46 497.54,317.85C498.88,318.23 500.23,318.42 501.6,318.42C502.97,318.42 504.32,318.23 505.67,317.85C507.01,317.46 508.31,316.89 509.57,316.14C510.84,315.4 512.05,314.48 513.18,313.39C514.32,312.3 515.38,311.06 516.34,309.67C517.31,308.29 518.17,306.78 518.93,305.15C519.7,303.52 520.33,301.8 520.86,299.99C521.38,298.17 521.78,296.31 522.04,294.39C522.31,292.47 522.45,290.53 522.45,288.57C522.45,286.6 522.31,284.66 522.04,282.74C521.78,280.82 521.38,278.95 520.86,277.14C520.33,275.33 519.7,273.61 518.93,271.98C518.17,270.35 517.31,268.84 516.34,267.45C515.38,266.07 514.32,264.83 513.18,263.74C512.05,262.65 510.84,261.73 509.57,260.98C508.31,260.23 507.01,259.66 505.67,259.28Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z" />
</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="M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
</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="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M120,800L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,403Q750,401 740,400.5Q730,400 720,400Q710,400 700,400.5Q690,401 680,403L680,200Q680,200 680,200Q680,200 680,200L200,200Q200,200 200,200Q200,200 200,200L200,600L483,600Q481,610 480.5,620Q480,630 480,640Q480,650 480.5,660Q481,670 483,680L240,680L120,800ZM280,360L600,360L600,280L280,280L280,360ZM280,520L480,520L480,440L280,440L280,520ZM680,800L680,680L560,680L560,600L680,600L680,480L760,480L760,600L880,600L880,680L760,680L760,800L680,800ZM200,600L200,600L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,403Q200,453 200,501.5Q200,550 200,600Z" />
</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="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</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="M520,840L520,760L600,760L600,840L520,840ZM440,760L440,560L520,560L520,760L440,760ZM760,640L760,480L840,480L840,640L760,640ZM680,480L680,400L760,400L760,480L680,480ZM200,560L200,480L280,480L280,560L200,560ZM120,480L120,400L200,400L200,480L120,480ZM480,200L480,120L560,120L560,200L480,200ZM180,300L300,300L300,180L180,180L180,300ZM120,360L120,120L360,120L360,360L120,360ZM180,780L300,780L300,660L180,660L180,780ZM120,840L120,600L360,600L360,840L120,840ZM660,300L780,300L780,180L660,180L660,300ZM600,360L600,120L840,120L840,360L600,360ZM680,840L680,720L600,720L600,640L760,640L760,760L840,760L840,840L680,840ZM520,560L520,480L680,480L680,560L520,560ZM360,560L360,480L280,480L280,400L520,400L520,480L440,480L440,560L360,560ZM400,360L400,200L480,200L480,280L560,280L560,360L400,360ZM210,270L210,210L270,210L270,270L210,270ZM210,750L210,690L270,690L270,750L210,750ZM690,270L690,210L750,210L750,270L690,270Z" />
</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="M80,280L80,80L280,80L280,160L160,160L160,280L80,280ZM80,880L80,680L160,680L160,800L280,800L280,880L80,880ZM680,880L680,800L800,800L800,680L880,680L880,880L680,880ZM800,280L800,160L680,160L680,80L880,80L880,280L800,280ZM700,700L760,700L760,760L700,760L700,700ZM700,580L760,580L760,640L700,640L700,580ZM640,640L700,640L700,700L640,700L640,640ZM580,700L640,700L640,760L580,760L580,700ZM520,640L580,640L580,700L520,700L520,640ZM640,520L700,520L700,580L640,580L640,520ZM580,580L640,580L640,640L580,640L580,580ZM520,520L580,520L580,580L520,580L520,520ZM760,200L760,440L520,440L520,200L760,200ZM440,520L440,760L200,760L200,520L440,520ZM440,200L440,440L200,440L200,200L440,200ZM380,700L380,580L260,580L260,700L380,700ZM380,380L380,260L260,260L260,380L380,380ZM700,380L700,260L580,260L580,380L700,380Z" />
</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="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,560Z" />
</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="M440,800L440,313L216,537L160,480L480,160L800,480L744,537L520,313L520,800L440,800Z" />
</vector>

View File

@@ -1,49 +1,254 @@
package su.reya.coop
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.expressiveLightColorScheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.painterResource
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.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.coroutines.launch
import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
import su.reya.coop.screens.MyQrScreen
import su.reya.coop.screens.NewChatScreen
import su.reya.coop.screens.NewIdentityScreen
import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.ScanScreen
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.compose_multiplatform
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
val LocalNavController = staticCompositionLocalOf<NavController> {
error("No NavController provided")
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@Preview
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.primaryContainer)
.safeContentPadding()
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
val context = LocalContext.current
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val darkMode = isSystemInDarkTheme()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Initialize Nostr View Model and Secret Store
val secretStore = remember { SecretStore(context) }
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
// Enabled the dynamic color scheme
val colorScheme = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkMode -> darkColorScheme()
else -> expressiveLightColorScheme()
}
LaunchedEffect(Unit) {
viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
) {
CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController,
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
val sheetState = rememberModalBottomSheetState()
LaunchedEffect(emptySecret) {
// Navigate to the home screen if the secret is already set
if (emptySecret == false) {
navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true }
}
}
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
// Show loading screen while initializing
if (emptySecret == null) return@CompositionLocalProvider
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { viewModel.dismissRelayWarning() },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messaging Relays are required",
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
style = MaterialTheme.typography.bodyLarge.copy(
fontStyle = FontStyle.Italic,
),
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
scope.launch {
viewModel.useDefaultMsgRelayList()
sheetState.hide()
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
NavHost(
navController = navController,
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
) {
composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen(
onOpenImport = { navController.navigate(Screen.Import) },
onOpenNew = { navController.navigate(Screen.NewIdentity) }
)
}
composable<Screen.Import> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
ImportScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
}
composable<Screen.NewIdentity> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
NewIdentityScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { name, bio, uri ->
val contentType = uri?.let { context.contentResolver.getType(it) }
val picture = uri?.let {
context.contentResolver.openInputStream(it)?.use { input ->
input.readBytes()
}
}
viewModel.createIdentity(name, bio, picture, contentType)
}
)
}
composable<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
onNewChat = { navController.navigate(Screen.NewChat) }
)
}
composable<Screen.Chat> { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(
id = chat.id,
onBack = { navController.popBackStack() },
)
}
composable<Screen.NewChat> { backStackEntry ->
NewChatScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Scan> { backStackEntry ->
ScanScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.MyQr> { backStackEntry ->
MyQrScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Relay> { backStackEntry ->
RelayScreen(
onBack = { navController.popBackStack() },
)
}
}
}
}
}
}

View File

@@ -1,25 +1,27 @@
package su.reya.coop
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val intent = Intent(this, NostrForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
setContent {
App()
}
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}

View File

@@ -0,0 +1,32 @@
package su.reya.coop
import kotlinx.serialization.Serializable
sealed interface Screen {
@Serializable
data object Home : Screen
@Serializable
data class Chat(val id: Long) : Screen
@Serializable
data object NewChat : Screen
@Serializable
data object Onboarding : Screen
@Serializable
data object Import : Screen
@Serializable
data object NewIdentity : Screen
@Serializable
data object Scan : Screen
@Serializable
data object MyQr : Screen
@Serializable
data object Relay : Screen
}

View File

@@ -0,0 +1,93 @@
package su.reya.coop
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.io.File
class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val nostr = NostrManager.instance
override fun onBind(intent: Intent?): IBinder? = null
private fun isUserInApp(): Boolean {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel()
val notification = createNotification("Connecting to Nostr...")
startForeground(1, notification)
serviceScope.launch {
try {
val dbDir = File(filesDir, "nostr")
dbDir.mkdirs()
// Initialize Nostr client
nostr.init(dbDir.absolutePath)
// Connect to bootstrap relays
nostr.connectBootstrapRelays()
// Handle notifications
nostr.handleLiteNotifications { event ->
if (!isUserInApp()) {
showNewMessageNotification(event.content())
}
}
} catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}")
}
}
return START_STICKY
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
val channel = NotificationChannel(
"nostr_service",
"Nostr Background Service",
NotificationManager.IMPORTANCE_HIGH
)
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
private fun createNotification(content: String): Notification {
return NotificationCompat.Builder(this, "nostr_service")
.setContentTitle("Coop")
.setContentText(content)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setOngoing(true)
.build()
}
private fun showNewMessageNotification(message: String) {
val notification = NotificationCompat.Builder(this, "nostr_service")
.setContentTitle("New Message")
.setContentText(message)
.setAutoCancel(true)
.build()
val manager = getSystemService(NotificationManager::class.java)
manager?.notify(System.currentTimeMillis().toInt(), notification)
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
}

View File

@@ -0,0 +1,349 @@
package su.reya.coop.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
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.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.formatAsGroupHeader
import su.reya.coop.roomId
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@Composable
fun ChatScreen(
id: Long,
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val room = viewModel.getChatRoom(id)
val listState = rememberLazyListState()
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) {
messages.groupBy { it.createdAt().formatAsGroupHeader() }
}
fun setLoading(value: Boolean) {
loading = value
}
LaunchedEffect(id) {
// Start loading spinner
setLoading(true)
// Get messages
val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
// Get msg relays for each member
val results = viewModel.chatRoomConnect(id)
results.forEach { (member, relays) ->
if (relays.isNotEmpty()) {
val metadata = viewModel.getMetadata(member).first { it != null }
val profile = metadata?.asRecord()
val name = profile?.displayName ?: profile?.name ?: member.short()
snackbarHostState.showSnackbar("Connected to messaging relays for $name")
}
}
// Stop loading spinner
setLoading(false)
// Handle new messages
viewModel.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
}
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (loading) {
LoadingIndicator(
modifier = Modifier.size(32.dp),
)
} else {
Avatar(
picture = picture,
description = displayName,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (messages.isNotEmpty()) {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
) {
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
items(
messagesInGroup,
key = { it.id()?.toBech32()!! }) { event ->
ChatMessage(event)
}
item {
DateSeparator(dateHeader)
}
}
}
} else {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(id, text)
text = ""
}
)
}
}
}
)
}
@Composable
fun DateSeparator(date: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = date,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
@Composable
fun ChatMessage(
rumor: UnsignedEvent
) {
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser()
val isMine = rumor.author() == currentUser
val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
}
val containerColor =
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
val contentColor =
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Surface(
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
modifier = Modifier
.widthIn(max = 280.dp)
.clickable(
onClick = {
val id = rumor.id()
if (id != null) {
val sent = viewModel.isMessageSent(id)
println("Sent: $sent")
}
}
)
) {
Text(
text = rumor.content(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit
) {
Surface(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Bottom
) {
TextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text("Message") },
shape = RoundedCornerShape(28.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton(
onClick = onSend,
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = painterResource(Res.drawable.ic_send),
contentDescription = "Send"
)
}
}
}
}

View File

@@ -0,0 +1,441 @@
package su.reya.coop.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
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.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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.draw.clip
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen
import su.reya.coop.ago
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onOpenChat: (Long) -> Unit,
onNewChat: () -> Unit,
) {
val clipboard = LocalClipboard.current
val navController = LocalNavController.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
val userProfile by currentUserProfile.collectAsState(initial = null)
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
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) }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(Unit) {
viewModel.getChatRooms()
}
LaunchedEffect(qrResult) {
qrResult?.let { result ->
runCatching { PublicKey.parse(result) }
.onSuccess { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
}
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Text(
text = "Coop",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
actions = {
// QR Scanner
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
// User
IconButton(onClick = { showBottomSheet = true }) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
size = 32.dp,
)
}
}
)
},
floatingActionButton = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
tooltip = {
if (!expandedFab) {
PlainTooltip { Text("New Chat") }
}
},
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = onNewChat,
expanded = expandedFab,
icon = {
Icon(
painter = painterResource(Res.drawable.ic_new_chat),
contentDescription = "New Chat"
)
},
text = { Text("New Chat") },
)
}
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
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 (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { onOpenChat(room.id) }
)
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
}
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
dismissAndRun { navController.navigate(Screen.MyQr) }
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
scope.launch {
sheetState.hide()
showBottomSheet = false
navController.navigate(Screen.MyQr)
}
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
}
},
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatRoom(room: Room, onClick: () -> Unit) {
val viewModel = LocalNostrViewModel.current
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = {
Avatar(picture = picture, description = displayName)
},
headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.weight(1f)
)
Text(
text = room.createdAt.ago(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
},
supportingContent = {
if (!room.lastMessage.isNullOrBlank()) {
Text(
text = room.lastMessage!!,
style = MaterialTheme.typography.bodyMedium
)
}
},
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit
) {
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current
val defaultMenuList = listOf(
"Relay Management" to { navController.navigate(Screen.Relay) },
"Spams & Blocks" to { },
"Contacts" to { },
"Settings" to { }
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
defaultMenuList.forEachIndexed { index, (title, action) ->
SegmentedListItem(
onClick = { onDismiss { action() } },
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = defaultMenuList.size
),
content = { Text(text = title) },
)
}
}
Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton(
onClick = { viewModel.logout() },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(text = "Logout")
}
}
}

View File

@@ -0,0 +1,256 @@
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
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.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (secret: String) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
val metadata by remember(pubkey) {
if (pubkey != null) {
viewModel.getMetadata(pubkey!!)
} else {
MutableStateFlow(null)
}
}.collectAsState(null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(qrResult) {
qrResult?.let { result ->
runCatching {
if (result.startsWith("nsec")) {
Keys.parse(result)
} else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result)
} else {
throw IllegalArgumentException("Invalid secret: $result")
}
}
.onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Import",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
}
)
},
content = { innerPadding ->
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Pentagon.toShape(),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Enter your Secret Key or Bunker URI:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = secret,
onValueChange = { secret = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (secret.isEmpty()) {
Text(
"bunker://",
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
if (pubkey == null) {
scope.launch {
viewModel.verifyIdentity(secret).let { pubkey = it }
}
} else {
onSave(secret)
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = if (pubkey == null) "Verify" else "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
)
}

View File

@@ -0,0 +1,68 @@
package su.reya.coop.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
@Composable
fun MyQrScreen(
onBack: () -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "My QR",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
}
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = rememberQrCodePainter(currentUser.toBech32()),
contentDescription = "My QR"
)
}
}
)
}

View File

@@ -0,0 +1,406 @@
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_arrow_next
import coop.composeapp.generated.resources.ic_close_small
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewChatScreen(
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
val createGroup = remember { mutableStateOf(false) }
val searchResults = remember { mutableStateListOf<PublicKey>() }
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(query) {
if (query.length >= 3) {
delay(500) // 500ms debounce
if (query.startsWith("npub1")) {
val pubkey = try {
PublicKey.parse(query)
} catch (e: Exception) {
println("Failed to parse npub: ${e.message}")
null
}
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
} else if (query.contains("@")) {
val pubkey = viewModel.searchByAddress(query)
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
} else {
val results = viewModel.searchByNostr(query)
searchResults.clear()
searchResults.addAll(results)
}
query = ""
}
}
LaunchedEffect(qrResult) {
qrResult?.let { result ->
runCatching { PublicKey.parse(result) }
.onSuccess { pubkey -> selectedReceivers.add(pubkey) }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = if (createGroup.value) "New group chat" else "New chat",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
}
)
},
floatingActionButton = {
if (selectedReceivers.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
tooltip = {
PlainTooltip { Text("Next") }
},
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
navController.navigate(Screen.Chat(roomId))
},
expanded = false,
icon = {
Icon(
painter = painterResource(Res.drawable.ic_arrow_next),
contentDescription = "Next"
)
},
text = { Text("Next") },
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "To:",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
selectedReceivers.forEach { receiver ->
ReceiverChip(
pubkey = receiver,
onRemove = { selectedReceivers.remove(receiver) }
)
}
BasicTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty()) {
Text(
"Type a npub or address",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
if (searchResults.isNotEmpty()) {
ContactList(
items = searchResults,
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
},
)
Spacer(modifier = Modifier.size(16.dp))
} else {
ContactList(
title = "Contacts",
items = contactList.toList(),
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
}
)
Spacer(modifier = Modifier.size(16.dp))
}
}
}
}
)
}
@Composable
fun ReceiverChip(
pubkey: PublicKey,
onRemove: () -> Unit
) {
val viewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip(
selected = true,
onClick = onRemove,
label = {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
},
avatar = {
Avatar(
picture = picture,
description = displayName,
size = 24.dp
)
},
trailingIcon = {
Icon(
painter = painterResource(Res.drawable.ic_close_small),
contentDescription = "Close"
)
},
shape = RoundedCornerShape(24.dp),
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactList(
title: String? = null,
items: List<PublicKey>,
selectedReceivers: SnapshotStateList<PublicKey>,
onContactClick: (PublicKey) -> Unit
) {
if (items.isEmpty()) return
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
if (title != null) {
Text(
text = title,
style = MaterialTheme.typography.titleMediumEmphasized,
)
Spacer(modifier = Modifier.size(8.dp))
}
items.forEachIndexed { index, item ->
ContactListItem(
pubkey = item,
index = index,
total = items.size,
isSelected = selectedReceivers.contains(item),
onClick = { onContactClick(item) },
onLongClick = {
if (!selectedReceivers.contains(item)) {
selectedReceivers.add(item)
}
}
)
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactListItem(
pubkey: PublicKey,
index: Int,
total: Int = 0,
isSelected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val viewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
SegmentedListItem(
selected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = total
),
leadingContent = {
Avatar(
picture = picture,
description = displayName,
size = 36.dp
)
},
supportingContent = { Text(text = pubkey.short()) },
content = {
Text(
text = displayName,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
)
}

View File

@@ -0,0 +1,245 @@
package su.reya.coop.screens
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
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.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewIdentityScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
picture = uri
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Create a new identity",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape())
.clickable { launcher.launch("image/*") },
contentAlignment = Alignment.Center
) {
if (picture != null) {
AsyncImage(
model = picture,
contentDescription = "Profile picture",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxSize()
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "Pick avatar",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What others should call you?",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
Text(
"Alice",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
onSave(name, bio, picture)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
)
}

View File

@@ -0,0 +1,159 @@
package su.reya.coop.screens
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
val snackbarHostState = LocalSnackbarHostState.current
val logoPainter = painterResource(Res.drawable.coop)
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
LogoRepeatingBackground(
painter = logoPainter,
logosPerRow = 6,
rotationDegrees = -25f,
horizontalOffset = 0.5f
)
Column(
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.weight(2f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
// TODO: Add headline
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(bottom = innerPadding.calculateBottomPadding()),
contentAlignment = Alignment.BottomEnd,
) {
Column(
modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
) {
Button(
onClick = onOpenNew,
modifier = Modifier
.fillMaxWidth()
.size(ButtonDefaults.LargeContainerHeight),
) {
Text(
text = "Start messaging",
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton(
onClick = onOpenImport,
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.LargeContainerHeight),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
) {
Text(
text = "Import identity",
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
}
}
}
}
}
)
}
@Composable
fun LogoRepeatingBackground(
painter: Painter,
logosPerRow: Int,
rotationDegrees: Float = 0f,
horizontalOffset: Float = 0.5f
) {
val tintColor = MaterialTheme.colorScheme.primary
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
val logoSize = canvasWidth / logosPerRow
val offsetX = logoSize * horizontalOffset
val extraPadding = 2
val cols = logosPerRow + (extraPadding * 2)
val rows = (canvasHeight / logoSize).toInt() + 1
for (row in 0 until rows) {
for (col in -extraPadding until cols) {
val px = (col * logoSize) - offsetX
val py = row * logoSize
rotate(
degrees = rotationDegrees,
pivot = Offset(
px + logoSize / 2,
py + logoSize / 2
)
) {
translate(left = px, top = py) {
with(painter) {
draw(
size = Size(logoSize, logoSize),
alpha = 0.1f,
colorFilter = ColorFilter.tint(
tintColor
)
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,236 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
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.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen(
onBack: () -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
val inboxRelays by remember {
derivedStateOf {
relayList.filter { it.value == RelayMetadata.READ || it.value == null }.keys.toList()
}
}
val outboxRelays by remember {
derivedStateOf {
relayList.filter { it.value == RelayMetadata.WRITE || it.value == null }.keys.toList()
}
}
LaunchedEffect(Unit) {
relayList.putAll(viewModel.currentUserRelayList())
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Text(
text = "Relay Management",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
}
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Messaging Relay List",
style = MaterialTheme.typography.titleMediumEmphasized,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
if (msgRelayList.isNotEmpty()) {
msgRelayList.forEachIndexed { index, relayUrl ->
SegmentedListItem(
onClick = { },
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = msgRelayList.size
),
content = { Text(text = relayUrl.toString()) },
)
}
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No relays configured",
style = MaterialTheme.typography.labelMediumEmphasized,
)
}
}
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Inbox Relays",
style = MaterialTheme.typography.titleMediumEmphasized,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
if (inboxRelays.isNotEmpty()) {
inboxRelays.forEachIndexed { index, relayUrl ->
SegmentedListItem(
onClick = { },
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = inboxRelays.size
),
content = { Text(text = relayUrl.toString()) },
)
}
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No relays configured",
style = MaterialTheme.typography.labelMediumEmphasized,
)
}
}
}
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Outbox Relays",
style = MaterialTheme.typography.titleMediumEmphasized,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
if (outboxRelays.isNotEmpty()) {
outboxRelays.forEachIndexed { index, relayUrl ->
SegmentedListItem(
onClick = { },
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = outboxRelays.size
),
content = { Text(text = relayUrl.toString()) },
)
}
} else {
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No relays configured",
style = MaterialTheme.typography.labelMediumEmphasized,
)
}
}
}
}
}
}
}
)
}

View File

@@ -0,0 +1,115 @@
package su.reya.coop.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import org.publicvalue.multiplatform.qrcode.CameraPosition
import org.publicvalue.multiplatform.qrcode.CodeType
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
import su.reya.coop.LocalNavController
import su.reya.coop.LocalSnackbarHostState
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun ScanScreen(
onBack: () -> Unit
) {
val navController = LocalNavController.current
val snackbarHostState = LocalSnackbarHostState.current
val onResult: (String) -> Unit = { result ->
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
navController.popBackStack()
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Scan QR",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White,
navigationIconContentColor = Color.White,
)
)
},
) { innerPadding ->
Box(modifier = Modifier.fillMaxSize()) {
ScannerWithPermissions(
modifier = Modifier.fillMaxSize(),
onScanned = {
println("Scanned: $it");
onResult(it)
true
},
types = listOf(CodeType.QR),
cameraPosition = CameraPosition.BACK,
enableTorch = false
)
Canvas(modifier = Modifier.fillMaxSize()) {
val scannerSize = 250.dp.toPx()
val left = (size.width - scannerSize) / 2
val top = (size.height - scannerSize) / 2
drawRect(color = Color.Black.copy(alpha = 0.6f))
drawRect(
color = Color.Transparent,
topLeft = Offset(left, top),
size = Size(scannerSize, scannerSize),
blendMode = BlendMode.Clear
)
}
Box(
modifier = Modifier
.size(250.dp)
.align(Alignment.Center)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
)
Text(
text = "Scan a Nostr address",
style = MaterialTheme.typography.titleSmallEmphasized,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 64.dp)
)
}
}
}

View File

@@ -0,0 +1,38 @@
package su.reya.coop.shared
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.avatar
import org.jetbrains.compose.resources.painterResource
@Composable
fun Avatar(
picture: String?,
description: String?,
modifier: Modifier = Modifier,
size: Dp = 48.dp,
shape: Shape = CircleShape
) {
val placeholder = painterResource(Res.drawable.avatar)
AsyncImage(
model = picture,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop,
fallback = placeholder,
error = placeholder,
placeholder = placeholder
)
}

View File

@@ -0,0 +1,33 @@
package su.reya.coop.shared
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import su.reya.coop.NostrViewModel
import su.reya.coop.Room
import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
val memberFlows = members.map { viewModel.getMetadata(it) }
return combine(memberFlows) { metadataArray ->
if (isGroup()) {
val profiles = metadataArray.map { it?.asRecord() }
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
var combined = names.joinToString(", ")
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
combined.ifBlank { "Unknown group" }
} else {
val profile = metadataArray.firstOrNull()?.asRecord()
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
}
}
}
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}

View File

@@ -0,0 +1,78 @@
package su.reya.coop.coop.storage
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
data class SecretEntry(
val encrypted: String,
val iv: String
)
class SecretCrypto {
private val keyAlias = "coop"
private val keyStoreType = "AndroidKeyStore"
private val transformation = "AES/GCM/NoPadding"
fun encrypt(content: String): SecretEntry {
// Initialize cipher
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
// Encrypt content
val encrypted = cipher.doFinal(content.toByteArray())
val iv = cipher.iv
return SecretEntry(
encrypted = Base64.encodeToString(encrypted, Base64.NO_WRAP),
iv = Base64.encodeToString(iv, Base64.NO_WRAP)
)
}
fun decrypt(entry: SecretEntry): String {
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
// Initialize cipher
val cipher = Cipher.getInstance(transformation)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
// Decrypt content
val plaintext = cipher.doFinal(encrypted)
return String(plaintext, StandardCharsets.UTF_8)
}
private fun getOrCreateKey(): SecretKey {
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
val existingKey = keyStore.getKey(keyAlias, null)
// Return existing key if available
if (existingKey is SecretKey) return existingKey
// Construct a new key generator
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
// Initialize key generation parameters
val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build()
// Generate a new key
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
}

View File

@@ -0,0 +1,43 @@
package su.reya.coop.coop.storage
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import su.reya.coop.storage.SecretStorage
private val Context.dataStore by preferencesDataStore("secret_store")
class SecretStore(private val context: Context) : SecretStorage {
private val crypto = SecretCrypto()
override suspend fun set(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun get(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs[stringPreferencesKey("${key}_encrypted")] != null
}
}

View File

@@ -9,4 +9,14 @@ org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
android.useAndroidX=true
android.useAndroidX=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.13.2"
agp = "9.2.1"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
@@ -8,11 +8,14 @@ androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-navigation = "2.8.8"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.3"
junit = "4.13.2"
kotlin = "2.3.20"
kotlinx-serialization = "1.8.0"
material3 = "1.10.0-alpha05"
ktor = "3.4.3"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -23,6 +26,8 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
@@ -32,6 +37,12 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
kotlin("plugin.serialization") version libs.versions.kotlin.get()
}
kotlin {
@@ -11,7 +12,7 @@ kotlin {
jvmTarget.set(JvmTarget.JVM_11)
}
}
listOf(
iosArm64(),
iosSimulatorArm64()
@@ -21,10 +22,24 @@ kotlin {
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
// put your Multiplatform dependencies here
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("com.squareup.okio:okio:3.16.2")
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
commonTest.dependencies {
implementation(libs.kotlin.test)

View File

@@ -1,9 +0,0 @@
package su.reya.coop
import android.os.Build
class AndroidPlatform : Platform {
override val name: String = "Android ${Build.VERSION.SDK_INT}"
}
actual fun getPlatform(): Platform = AndroidPlatform()

View File

@@ -0,0 +1,75 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.url
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import rust.nostr.sdk.ConnectionMode
import rust.nostr.sdk.CustomWebSocketTransport
import rust.nostr.sdk.WebSocketAdapter
import rust.nostr.sdk.WebSocketAdapterWrapper
import rust.nostr.sdk.WebSocketMessage
class KtorWebSocketAdapter(
private val client: HttpClient,
private val session: DefaultClientWebSocketSession
) : WebSocketAdapter {
override suspend fun send(msg: WebSocketMessage) {
try {
when (msg) {
is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
else -> {}
}
} catch (e: Exception) {
println("Attempted to send on a closed WebSocket: ${e.message}")
throw e
}
}
override suspend fun recv(): WebSocketMessage? {
return try {
when (val frame = session.incoming.receive()) {
is Frame.Text -> WebSocketMessage.Text(frame.readText())
is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
else -> null
}
} catch (e: ClosedReceiveChannelException) {
null
} catch (e: Exception) {
throw e
}
}
override suspend fun closeConnection() {
session.cancel()
session.close()
}
}
class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
override fun supportPing(): Boolean = false
override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
try {
val session = httpClient.webSocketSession {
url(url)
}
val adapter = KtorWebSocketAdapter(httpClient, session)
return WebSocketAdapterWrapper(adapter)
} catch (e: Exception) {
throw e
}
}
}

View File

@@ -1,9 +0,0 @@
package su.reya.coop
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View File

@@ -0,0 +1,870 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder
import rust.nostr.sdk.ClientNotification
import rust.nostr.sdk.Contact
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.GossipConfig
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.LogLevel
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.MetadataRecord
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.Nip05Profile
import rust.nostr.sdk.NostrDatabase
import rust.nostr.sdk.NostrGossip
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayMessageEnum
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SingleLetterTag
import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.giftWrapAsync
import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration
object NostrManager {
val instance = Nostr()
}
class Nostr {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
private set
var deviceSigner: AsyncNostrSigner? = null
private set
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
private set
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private set
suspend fun init(dbPath: String) {
try {
if (_isInitialized.value) return
// Initialize the logger for nostr client
initLogger(LogLevel.DEBUG)
val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory()
val idleTimeout = Duration.parse("5m")
val httpClient = HttpClient {
install(WebSockets)
}
client =
ClientBuilder()
.signer(signer)
.websocketTransport(CoopWebSocketClient(httpClient))
.database(lmdb)
.gossip(gossip)
.gossipConfig(
GossipConfig()
.noBackgroundRefresh()
.fetchTimeout(Duration.parse("2s"))
.syncIdleTimeout(Duration.parse("100ms"))
.syncInitialTimeout(Duration.parse("100ms"))
)
.verifySubscriptions(false)
.automaticAuthentication(true)
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build()
_isInitialized.value = true
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
}
}
suspend fun waitUntilInitialized() {
_isInitialized.first { it }
}
suspend fun connectBootstrapRelays() {
// Bootstrap relays
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
// Indexer relay for NIP-65 discovery
client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"),
capabilities = RelayCapabilities.gossip()
)
// Connect to all bootstrap relays and wait for all connections to be established
client?.connect(Duration.parse("2s"))
}
suspend fun disconnect() {
client?.shutdown()
}
suspend fun exit() {
signer.switch(Keys.generate())
deviceSigner = null
}
suspend fun setSigner(new: AsyncNostrSigner) {
try {
signer.switch(new)
// Fetch metadata for current user
getUserMetadata()
} catch (e: Exception) {
throw IllegalStateException("Failed to set signer: ${e.message}", e)
}
}
fun isSignedByUser(event: Event): Boolean {
return try {
signer.currentUser == event.author()
} catch (e: Exception) {
println("Failed to check if event is signed by user: ${e.message}")
false
}
}
suspend fun getUserMetadata() {
try {
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
// Get the latest metadata event
val metadataFilter =
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
// Get the latest contact list event
val contactFilter =
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
// Get the latest messaging relay list event
val msgRelayFilter =
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
// Construct a target that includes all filters
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
}
}
suspend fun getUserMessages(msgRelayList: Event) {
try {
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
val relays = nip17ExtractRelayList(msgRelayList)
// Ensure relay connections
relays.forEach { relay ->
client?.addRelay(relay)
client?.connectRelay(relay)
}
// Construct a filter for gift wrap events
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
val target = mutableMapOf<RelayUrl, List<Filter>>()
relays.forEach { relay ->
target[relay] = listOf(filter)
}
client?.subscribe(
target = ReqTarget.manual(target),
id = "all-gift-wraps"
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
}
}
suspend fun handleLiteNotifications(
onNewMessage: (UnsignedEvent) -> Unit,
) {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return
while (true) {
val notification = notifications.next() ?: continue
when (notification) {
is ClientNotification.Message -> {
val relayUrl = notification.relayUrl
when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> {
val event = message.event
val subscriptionId = message.subscriptionId
// Ignore events not from the newest gift wraps subscription
if (subscriptionId != "newest-gift-wraps") continue
// Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
val rumor = extractRumor(event)
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
} catch (e: Exception) {
println("Failed to extract rumor: $e")
}
}
}
else -> {
/* Ignore other event kinds */
}
}
}
else -> {
/* Ignore other message types */
}
}
}
}
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit,
) = coroutineScope {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@coroutineScope
var eoseTrackerJob: Job? = null
while (true) {
val notification = notifications.next() ?: continue
when (notification) {
is ClientNotification.Message -> {
val relayUrl = notification.relayUrl
when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> {
val event = message.event
val id = message.subscriptionId
// Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
try {
val metadata = Metadata.fromJson(event.content())
onMetadataUpdate(event.author(), metadata)
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
}
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
if (isSignedByUser(event = event)) {
onContactListUpdate(event.tags().publicKeys())
}
}
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event)
}
}
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
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) // Wait for 10 seconds
onSubscriptionClose()
}
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
} catch (e: Exception) {
println("Failed to extract rumor: $e")
}
}
}
is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
onSubscriptionClose()
}
}
is RelayMessageEnum.Ok -> {
if (sentEvents.containsKey(message.eventId)) {
val currentRelays = sentEvents[message.eventId] ?: emptyList()
sentEvents[message.eventId] = currentRelays + relayUrl
}
}
else -> {
/* Ignore other message types */
}
}
}
is ClientNotification.Shutdown -> {
break
}
else -> {
/* Ignore other message types */
}
}
}
}
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
try {
val filter = Filter().identifier(giftId.toHex())
val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
}
}
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
// Ensure the rumor ID is set
val rumor = rumor.ensureId()
val roomId = rumor.roomId()
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()),
Tag.custom(TagKind.Unknown("k"), listOf("dm"))
)
// Set event kind
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
val event = EventBuilder(kind, rumor.asJson())
.tags(tags)
.build(currentUser)
.signWithKeys(Keys.generate())
client?.database()?.saveEvent(event)
} catch (e: Exception) {
println("Failed to set cached rumor: ${e.message}")
}
}
private suspend fun extractRumor(event: Event): UnsignedEvent? {
// Check if the rumor is already cached
val cachedRumor = getCachedRumor(event.id())
if (cachedRumor != null) return cachedRumor
// Get all signers
val signers = listOfNotNull(signer, deviceSigner)
if (signers.isEmpty()) return null
// Try to unwrap the gift with each signer
for (signer in signers) {
try {
// TODO: custom unwrapping logic
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
val rumor = gift.rumor()
// Save the rumor to the database
setCachedRumor(event.id(), rumor)
// Return the rumor
return rumor
} catch (e: Exception) {
println("Failed to unwrap gift: ${e.message}")
continue
}
}
return null
}
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
// Construct a list of relays
val relayList = mapOf<RelayUrl, RelayMetadata>(
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,
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
)
// Ensure all relays are added and connected
relayList.forEach { (relay, metadata) ->
client?.addRelay(
url = relay,
capabilities =
if (metadata == RelayMetadata.READ) RelayCapabilities.read()
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
else RelayCapabilities.none()
)
client?.connectRelay(relay)
}
return relayList
}
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
// Construct a list of messaging relays
val msgRelayList = listOf(
RelayUrl.parse("wss://relay.0xchat.com"),
RelayUrl.parse("wss://nip17.com"),
)
// Ensure all relays are added and connected
msgRelayList.forEach { relay ->
client?.addRelay(relay, RelayCapabilities.none())
client?.connectRelay(relay)
}
return msgRelayList
}
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event
val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
client?.sendEvent(
event = relayListEvent,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.all(),
okTimeout = Duration.parse("3s")
)
// Send messaging relay list event
val msgRelayList = getDefaultMsgRelayList()
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
client?.sendEvent(
event = msgRelayListEvent,
target = SendEventTarget.toNip65(),
ackPolicy = AckPolicy.none()
)
// Send metadata event
val metadata =
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
client?.sendEvent(
event = metadataEvent,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
// Send contact list event
val defaultContact =
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
client?.sendEvent(
event = contactListEvent,
target = SendEventTarget.toNip65(),
ackPolicy = AckPolicy.none()
)
setSigner(keys)
}
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
try {
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
val events = client?.database()?.query(filter)
val results = mutableMapOf<PublicKey, Metadata>()
events?.toVec()?.forEach { event ->
val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
}
}
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
try {
val limit = keys.size.toULong() * 4u;
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
// Construct a filter for metadata events
val filter = Filter()
.kind(Kind.fromStd(KindStandard.METADATA))
.authors(keys)
.limit(limit)
// Construct a target that includes all filters
val target =
ReqTarget.manual(
mapOf(
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
)
)
client?.subscribe(target = target, closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
}
}
suspend fun setMsgRelays(urls: List<RelayUrl>) {
try {
val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.toNip65(),
ackPolicy = AckPolicy.none(),
)
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)
client?.subscribe(target = target, closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
try {
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
} catch (e: Exception) {
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
}
}
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun getChatRooms(): Set<Room>? {
try {
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm")
val events = client?.database()?.query(filter)
// Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) }
?.filter { it.tags().publicKeys().isNotEmpty() }
?.forEach { event ->
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
val existingRoom = roomsMap[newRoom.id]
// Check if the room already exists
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
val filter =
Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList())
// Determine if it's an ongoing room
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
// Append room to map
roomsMap[newRoom.id] =
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
}
}
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
return null
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val filter = Filter().kind(kind).reference(roomId.toString())
val events = client?.database()?.query(filter)
// Merge the events
return events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
}
}
suspend fun chatRoomConnect(members: List<PublicKey>): Map<PublicKey, List<RelayUrl>> {
try {
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
members.forEach { member ->
results[member] = mutableListOf<RelayUrl>()
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(member).limit(1u)
val stream = client?.streamEvents(
target = ReqTarget.auto(listOf(filter)),
id = "room-${member.toBech32().substring(0, 10)}",
timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose
)
stream?.next()?.let { res ->
if (res.event != null) {
// Connect to the msg relays
connectMsgRelays(res.event!!)
// Mark the member as connected
results[member]?.add(res.relayUrl)
}
}
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
}
}
suspend fun connectMsgRelays(event: Event) {
try {
val urls = nip17ExtractRelayList(event);
for (url in urls) {
if (client?.relay(url) == null) {
client?.addRelay(url)
client?.connectRelay(url)
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
}
}
suspend fun sendMessage(
to: List<PublicKey>,
content: String,
subject: String? = null,
replies: List<EventId> = emptyList(),
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
// Add a subject tag if provided
if (subject != null) {
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
}
// Add event tags for replies
if (replies.isNotEmpty()) {
replies.forEach { replyId ->
tags.add(Tag.event(replyId))
}
}
// Add public key tags for each recipient
to.forEach { pubkey ->
if (pubkey != currentUser) {
tags.add(Tag.publicKey(pubkey))
}
}
for (receiver in listOf(currentUser) + to) {
// Construct the rumor event
// NEVER SIGN this event with the current user signer
val rumor = EventBuilder
.privateMsgRumor(receiver = receiver, message = content)
.tags(tags)
.build(currentUser)
// Ensure the event ID is set
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = giftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom(TagKind.Unknown("k"), listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of sent events
sentEvents[output.id] = emptyList()
if (rumor.id() != null) rumorMap[rumor.id()!!] = output.id
// Collect failed outputs
output.failed.forEach { (relayUrl, reason) ->
println("Failed to send event to relay $relayUrl: $reason")
}
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
try {
val response: HttpResponse = client.get(address.url())
val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
}
}
suspend fun searchByAddress(query: String): PublicKey {
try {
val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address)
return profile.publicKey()
} catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e)
}
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
// Add search relay
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
if (client?.relay(searchRelay) == null) {
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
client?.connectRelay(searchRelay)
}
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u)
val target =
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
val stream = client?.streamEvents(
target = target,
id = "search",
timeout = Duration.parse("4s"),
policy = ReqExitPolicy.ExitOnEose
)
// Collect the results
val results = mutableListOf<PublicKey>()
// Keep searching until the stream is closed or timeout
stream?.next()?.let { event ->
if (event.event != null) {
results.add(event.event!!.author())
}
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,533 @@
package su.reya.coop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage
import kotlin.time.Clock
import kotlin.time.Duration
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
private val _emptySecret = MutableStateFlow<Boolean?>(null)
val emptySecret = _emptySecret.asStateFlow()
private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
val sentReport = _sentReports.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
startNotificationHandler()
startMetadataBatchHandler()
getCacheMetadata()
login()
observeSignerAndCheckRelays()
}
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
if (isCreating.value) _isCreating.value = false
}
}
private fun startNotificationHandler() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata)
},
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
if (!_isPartialProcessedGiftWrap.value) {
_isPartialProcessedGiftWrap.value = true
}
},
onNewMessage = { event ->
viewModelScope.launch {
_newEvents.emit(event)
}
},
)
}
}
private fun startMetadataBatchHandler() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout) {
metadataRequestChannel.receive()
}
if (nextKey != null) {
batch.add(nextKey)
}
val now = Clock.System.now().toEpochMilliseconds()
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.fetchMetadataBatch(keysToRequest)
}
}
}
}
}
private fun getCacheMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
val results = nostr.getAllCacheMetadata()
results.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
seenPublicKeys.add(pubkey)
}
}
}
private fun login() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
when (secret) {
null -> {
_emptySecret.value = true
return@launch
}
else -> _emptySecret.value = false
}
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
}
}
}
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
while (true) {
val pubkey = nostr.signer.currentUser
if (pubkey != null) {
delay(3000)
val relays = nostr.getMsgRelays(pubkey)
if (relays.isEmpty()) {
_isRelayListEmpty.value = true
}
break
}
delay(1000)
}
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
}
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) {
requestMetadata(pubkey)
}
return flow.asStateFlow()
}
fun currentUser(): PublicKey? {
return nostr.signer.currentUser
}
fun logout() {
viewModelScope.launch {
secretStore.clear("user_signer")
nostr.signer.switch(Keys.generate())
_emptySecret.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get("app_keys")
// If app keys are already stored, use them
if (secret != null) {
return Keys.parse(secret)
}
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
secretStore.set("app_keys", keys.secretKey().toBech32())
return keys
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
var avatarUrl = ""
// Set loading state
_isCreating.value = true
// Upload picture to Blossom
if (picture != null) {
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = picture,
contentType = contentType,
signer = keys
)
avatarUrl = descriptor?.url ?: ""
}
// Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Save secret to the secret storage
secretStore.set("user_signer", secret)
// Set an empty secret state
_emptySecret.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun verifyIdentity(secret: String): PublicKey? {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
return keys.publicKey()
} else if (secret.startsWith("bunker://")) {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
// Show toast to ask user to approve the connection
showError("Please approve the connection.")
return remote.getPublicKeyAsync()
} else {
throw IllegalArgumentException("Invalid secret: $secret")
}
}
fun importIdentity(secret: String) {
viewModelScope.launch {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
secretStore.set("user_signer", secret)
// Set an empty secret state
_emptySecret.value = false
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote)
secretStore.set("user_signer", secret)
// Set an empty secret state
_emptySecret.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
showError("Please enter a valid Secret or Bunker URI.")
}
}
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.getDefaultMsgRelayList()
nostr.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
try {
return nostr.getRelayList(nostr.signer.currentUser!!)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
try {
return nostr.getMsgRelays(nostr.signer.currentUser!!)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
fun createChatRoom(to: List<PublicKey>): Long {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
// Construct the rumor event
val rumor = EventBuilder
.privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) })
.build(nostr.signer.currentUser!!)
// Create a room from the rumor event
val room = Room.new(rumor, nostr.signer.currentUser!!)
_chatRooms.value += room
return room.id
}
fun getChatRoom(id: Long): Room {
return chatRooms.value.firstOrNull { it.id == id }
?: throw IllegalArgumentException("Room not found")
}
fun getChatRooms() {
viewModelScope.launch {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun refreshChatRooms() {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.getChatRoomMessages(roomId)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
val room = getChatRoom(roomId)
val members = room.members
return runCatching {
nostr.chatRoomConnect(members.toList())
}.getOrElse { e ->
showError("Error: ${e.message}")
members.associateWith { emptyList<RelayUrl>() }
}
}
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
viewModelScope.launch {
try {
val room = getChatRoom(roomId)
nostr.sendMessage(
to = room.members.toList(),
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
viewModelScope.launch { _newEvents.emit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.rumorMap[id]
if (giftWrapId != null) {
val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.value = _chatRooms.value.map { room ->
if (room.id == roomId) {
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt())
} else {
room
}
}.toSet()
}
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.searchByAddress(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.searchByNostr(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
}
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}

View File

@@ -1,7 +0,0 @@
package su.reya.coop
interface Platform {
val name: String
}
expect fun getPlatform(): Platform

View File

@@ -0,0 +1,171 @@
package su.reya.coop
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Clock
import kotlin.time.Instant
enum class RoomKind {
Ongoing,
Request;
companion object {
fun default(): RoomKind = Request
}
}
data class Room(
val id: Long,
val createdAt: Timestamp,
val subject: String?,
val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default(),
val lastMessage: String? = null
) : Comparable<Room> {
override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
}
companion object {
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt()
val subject = rumor.tags().find(TagKind.Subject)?.content()
// Collect the author's public key and all public keys from tags
// Also remove the user's public key from the list, current user is always a member
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys())
pubkeys.remove(userPubkey)
// Create a new Room instance
return Room(
id = id,
createdAt = createdAt,
subject = subject,
members = pubkeys as Set<PublicKey>,
lastMessage = rumor.content()
)
}
}
fun setKind(kind: RoomKind): Room {
return this.copy(kind = kind)
}
fun setCreatedAt(createdAt: Timestamp): Room {
return this.copy(createdAt = createdAt)
}
fun setSubject(subject: String?): Room {
return this.copy(subject = subject)
}
fun setLastMessage(message: String?): Room {
return this.copy(lastMessage = message)
}
fun isGroup(): Boolean {
return members.size > 1
}
}
fun UnsignedEvent.roomId(): Long {
// Collect the author's public key and all public keys from tags
val pubkeys: MutableList<PublicKey> = mutableListOf()
pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys())
// Sort and hash the list of public keys
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
}
fun Timestamp.ago(): String {
val SECONDS_IN_MINUTE = 60L
val MINUTES_IN_HOUR = 60L
val HOURS_IN_DAY = 24L
val DAYS_IN_MONTH = 30L
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val now = Clock.System.now()
val duration = now - inputInstant
return when {
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
else -> {
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val month =
localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
val day = localDateTime.dayOfMonth.toString().padStart(2, '0')
"$month $day"
}
}
}
fun Timestamp.formatAsGroupHeader(): String {
val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDate = inputInstant.toLocalDateTime(timeZone).date
val now = Clock.System.now()
val today = now.toLocalDateTime(timeZone).date
val yesterday = today.minus(1, DateTimeUnit.DAY)
return when (inputDate) {
today -> "Today"
yesterday -> "Yesterday"
else -> {
val day = inputDate.day.toString().padStart(2, '0')
val month = inputDate.month.number.toString().padStart(2, '0')
val year = inputDate.year.toString().takeLast(2)
"$day/$month/$year"
}
}
}
fun Timestamp.humanReadable(): String {
val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
val inputDate = inputDateTime.date
val now = Clock.System.now()
val today = now.toLocalDateTime(timeZone).date
val yesterday = today.minus(1, DateTimeUnit.DAY)
val hour = inputDateTime.hour
val minute = inputDateTime.minute.toString().padStart(2, '0')
val amPm = if (hour < 12) "AM" else "PM"
val hour12 = when {
hour == 0 -> 12
hour > 12 -> hour - 12
else -> hour
}
val timeFormat = "$hour12:$minute $amPm"
return when (inputDate) {
today -> "Today at $timeFormat"
yesterday -> "Yesterday at $timeFormat"
else -> {
val day = inputDateTime.day.toString().padStart(2, '0')
val month = inputDateTime.month.number.toString().padStart(2, '0')
val year = inputDateTime.year.toString().takeLast(2)
"$day/$month/$year, $timeFormat"
}
}
}

View File

@@ -0,0 +1,55 @@
package su.reya.coop
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
private val mutex = Mutex()
private var signer: AsyncNostrSigner = initialSigner
var currentUser: PublicKey? = null
private set
/**
* Get the current signer.
*/
suspend fun get(): AsyncNostrSigner = mutex.withLock {
signer
}
/**
* Switch to a new signer.
*/
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
signer = newSigner
currentUser = newSigner.getPublicKeyAsync()
}
override suspend fun getPublicKeyAsync(): PublicKey? {
return get().getPublicKeyAsync()
}
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
return get().signEventAsync(unsignedEvent)
}
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
return get().nip04EncryptAsync(publicKey, content)
}
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
return get().nip04DecryptAsync(publicKey, encryptedContent)
}
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
return get().nip44EncryptAsync(publicKey, content)
}
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
return get().nip44DecryptAsync(publicKey, payload)
}
}

View File

@@ -0,0 +1,83 @@
package su.reya.coop.blossom
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.header
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.HeaderValue
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.core.toByteArray
import okio.ByteString.Companion.toByteString
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Timestamp
import kotlin.io.encoding.Base64
import kotlin.time.Duration
class BlossomClient(
val url: String,
val client: HttpClient,
) {
suspend fun upload(
file: ByteArray,
contentType: String? = null,
signer: AsyncNostrSigner? = null
): BlobDescriptor? {
val url = "$url/upload"
val hash = file.toByteString().sha256().hex()
val fileHashes = listOf(hash)
val res = client.put(url) {
// Set body
setBody(file)
// Set the content type if provided
contentType?.let {
header(HttpHeaders.ContentType, it)
}
signer?.let {
val defaultAuth = defaultAuth(
action = BlossomAuthorizationVerb.Upload,
defaultContent = "Blossom upload authorization",
defaultScope = BlossomAuthorizationScope.BlobSha256Hashes(fileHashes)
)
val authHeader = buildAuthHeader(it, defaultAuth)
header(HttpHeaders.Authorization, authHeader.value)
}
}
return when (res.status) {
HttpStatusCode.OK -> res.body<BlobDescriptor>()
else -> {
throw Exception("Failed to upload file: ${res.status}")
}
}
}
fun defaultAuth(
action: BlossomAuthorizationVerb,
defaultContent: String,
defaultScope: BlossomAuthorizationScope
): BlossomAuthorization {
val expiration = Timestamp.now().addDuration(Duration.parse("300s"))
return BlossomAuthorization(
content = defaultContent,
expiration = expiration,
action = action,
scope = defaultScope
)
}
suspend fun buildAuthHeader(
signer: AsyncNostrSigner,
authz: BlossomAuthorization
): HeaderValue {
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
val value = "Nostr $encodedAuth"
return HeaderValue(value)
}
}

View File

@@ -0,0 +1,92 @@
package su.reya.coop.blossom
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Kind
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
/**
* Represents the authorization data for accessing a Blossom server.
*/
data class BlossomAuthorization(
/**
* A human readable string explaining to the user what the events intended use is
*/
val content: String,
/**
* A UNIX timestamp (in seconds) indicating when the authorization should be expired
*/
val expiration: Timestamp,
/**
* The type of action authorized by the user
*/
val action: BlossomAuthorizationVerb,
/**
* The scope of the authorization
*/
val scope: BlossomAuthorizationScope,
)
/**
* The scope of a Blossom authorization event
*/
sealed class BlossomAuthorizationScope {
/**
* Authorizes access to blobs with the given SHA256 hashes.
*/
data class BlobSha256Hashes(val hashes: List<String>) : BlossomAuthorizationScope()
/**
* Authorizes access to the given server URL.
*/
data class ServerUrl(val url: String) : BlossomAuthorizationScope()
fun toTags(): List<Tag> {
return when (this) {
is BlobSha256Hashes -> hashes.map { hash ->
// "x" tag for blob hash
Tag.parse(listOf("x", hash))
}
is ServerUrl -> listOf(
// "server" tag for server URL
Tag.parse(listOf("server", url))
)
}
}
}
/**
* Represents the possible actions that can be authorized by a Blossom authorization event.
*/
enum class BlossomAuthorizationVerb(val value: String) {
Get("get"),
Upload("upload"),
List("list"),
Delete("delete");
override fun toString(): String = value
}
/**
* Extension functions for [BlossomAuthorization] and [EventBuilder].
*/
fun BlossomAuthorization.toTags(): List<Tag> {
val tags = mutableListOf<Tag>()
tags.addAll(scope.toTags())
tags.add(Tag.expiration(expiration))
// Add the 't' tag to say what this auth is for
tags.add(Tag.hashtag(action.toString()))
return tags
}
/**
* Blossom authorization event (Kind 24242)
*
* https://github.com/hzrd149/blossom/blob/master/buds/01.md
*/
fun EventBuilder.Companion.blossomAuth(authorization: BlossomAuthorization): EventBuilder {
// Kind 24242 is used for Blossom Auth
val kind = Kind(24242u)
return EventBuilder(kind, authorization.content).tags(authorization.toTags())
}

View File

@@ -0,0 +1,27 @@
package su.reya.coop.blossom
import kotlinx.serialization.Serializable
@Serializable
data class BlobDescriptor(
/**
* The URL at which the blob/file can be accessed
*/
val url: String,
/**
* The SHA256 hash of the contents in the blob
*/
val sha256: String,
/**
* The size of the blob/file, in bytes
*/
val size: Long,
/**
* Mime type of the blob/file
*/
val mimeType: String? = null,
/**
* The date at which the blob was uploaded, as a UNIX timestamp (in seconds)
*/
val uploaded: ULong
)

View File

@@ -0,0 +1,8 @@
package su.reya.coop.storage
interface SecretStorage {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}

View File

@@ -1,9 +0,0 @@
package su.reya.coop
import platform.UIKit.UIDevice
class IOSPlatform: Platform {
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}
actual fun getPlatform(): Platform = IOSPlatform()