Compare commits
15 Commits
v0.1.4
...
improve-re
| Author | SHA1 | Date | |
|---|---|---|---|
| f7d2866517 | |||
| a759ad48e4 | |||
| 0d6b92b0c7 | |||
| 6a69d3a5b2 | |||
| 50b7f7a3f3 | |||
| a65aa70a55 | |||
| 74a37320fe | |||
| b8b3b83952 | |||
| 5c2115e8b7 | |||
| ec337b8756 | |||
| fcae7d5825 | |||
| 1e90b8d4b1 | |||
| 71a8240b1d | |||
| ff383a7c6a | |||
| 15e8c984e2 |
@@ -24,7 +24,7 @@ kotlin {
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.6")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||
@@ -69,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.1.4"
|
||||
versionName = "0.1.8"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="nostrsigner" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -19,6 +27,12 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".CrashActivity"
|
||||
android:exported="false"
|
||||
android:process=":crash_handler"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,147 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
|
||||
class AndroidExternalSigner(
|
||||
private val context: Context,
|
||||
private val launcher: ExternalSignerLauncher,
|
||||
) : ExternalSignerHandler {
|
||||
private var cachedPackageName: String? = null
|
||||
|
||||
private data class ContentResolverResult(
|
||||
val result: String,
|
||||
val event: String? = null,
|
||||
)
|
||||
|
||||
private fun queryContentResolver(
|
||||
type: String,
|
||||
payload: String,
|
||||
pubkey: PublicKey? = null,
|
||||
currentUser: PublicKey? = null,
|
||||
): ContentResolverResult? {
|
||||
val uri = "content://$cachedPackageName.${type.uppercase()}".toUri()
|
||||
val projection = mutableListOf<String?>().apply {
|
||||
add(payload)
|
||||
add(pubkey?.toHex() ?: "")
|
||||
add(currentUser?.toHex() ?: "")
|
||||
}
|
||||
|
||||
val cursor = context.contentResolver.query(
|
||||
uri,
|
||||
projection.toTypedArray(),
|
||||
null, null, null,
|
||||
) ?: return null
|
||||
|
||||
return cursor.use {
|
||||
if (it.getColumnIndex("rejected") > -1) return null
|
||||
if (it.moveToFirst()) {
|
||||
val resultIndex = it.getColumnIndex("result")
|
||||
val result = if (resultIndex > -1) it.getString(resultIndex) else null
|
||||
|
||||
val eventIndex = it.getColumnIndex("event")
|
||||
val event = if (eventIndex > -1) it.getString(eventIndex) else null
|
||||
|
||||
ContentResolverResult(result = result!!, event = event)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun request(
|
||||
type: String,
|
||||
payload: String,
|
||||
pubkey: PublicKey? = null,
|
||||
currentUser: PublicKey? = null,
|
||||
resultKey: String = "result",
|
||||
extras: Map<String, String> = emptyMap(),
|
||||
): String? {
|
||||
// Try Content Resolver first
|
||||
queryContentResolver(type, payload, pubkey, currentUser)?.let {
|
||||
return it.result
|
||||
}
|
||||
|
||||
// Fall back to Intent
|
||||
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
|
||||
`package` = cachedPackageName
|
||||
putExtra("type", type)
|
||||
if (pubkey != null) putExtra("pubkey", pubkey.toHex())
|
||||
if (currentUser != null) putExtra("current_user", currentUser.toHex())
|
||||
extras.forEach { (k, v) -> putExtra(k, v) }
|
||||
}
|
||||
|
||||
val result = launcher.launch(intent)
|
||||
if (result.resultCode != Activity.RESULT_OK) return null
|
||||
|
||||
val data = result.data ?: return null
|
||||
if (data.getBooleanExtra("rejected", false)) return null
|
||||
|
||||
return data.getStringExtra(resultKey)
|
||||
}
|
||||
|
||||
override fun isAvailable(): Boolean {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
|
||||
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
|
||||
}
|
||||
|
||||
override fun setPackageName(packageName: String) {
|
||||
cachedPackageName = packageName
|
||||
}
|
||||
|
||||
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
|
||||
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
|
||||
putExtra("type", "get_public_key")
|
||||
if (permissions != null) putExtra("permissions", permissions)
|
||||
}
|
||||
|
||||
val result = launcher.launch(intent)
|
||||
if (result.resultCode != Activity.RESULT_OK) return null
|
||||
|
||||
val data = result.data ?: return null
|
||||
if (data.getBooleanExtra("rejected", false)) return null
|
||||
|
||||
val pubkey = data.getStringExtra("result") ?: return null
|
||||
val packageName = data.getStringExtra("package") ?: return null
|
||||
cachedPackageName = packageName
|
||||
|
||||
return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
|
||||
}
|
||||
|
||||
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
|
||||
val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
|
||||
return request(
|
||||
type = "sign_event",
|
||||
payload = event.asJson(),
|
||||
currentUser = currentUser,
|
||||
resultKey = "event",
|
||||
extras = extras,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
|
||||
return request("nip04_encrypt", plaintext, pubkey)
|
||||
}
|
||||
|
||||
override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
|
||||
return request("nip04_decrypt", ciphertext, pubkey)
|
||||
}
|
||||
|
||||
override suspend fun nip44Encrypt(
|
||||
plaintext: String,
|
||||
pubkey: PublicKey,
|
||||
currentUser: PublicKey
|
||||
): String? {
|
||||
return request("nip44_encrypt", plaintext, pubkey, currentUser)
|
||||
}
|
||||
|
||||
override suspend fun nip44Decrypt(
|
||||
ciphertext: String,
|
||||
pubkey: PublicKey,
|
||||
currentUser: PublicKey
|
||||
): String? {
|
||||
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,15 @@ package su.reya.coop
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.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.MotionScheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
@@ -31,21 +20,15 @@ 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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.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.core.util.Consumer
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
@@ -53,7 +36,6 @@ import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
@@ -64,6 +46,7 @@ import su.reya.coop.screens.OnboardingScreen
|
||||
import su.reya.coop.screens.ProfileScreen
|
||||
import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
|
||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
@@ -92,8 +75,7 @@ fun App(viewModel: NostrViewModel) {
|
||||
val navigator = remember(backStack) { Navigator(backStack) }
|
||||
val qrScanResult = remember { QrScanResult() }
|
||||
|
||||
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
||||
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -104,7 +86,7 @@ fun App(viewModel: NostrViewModel) {
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
// Enable the dynamic color scheme for Android 12+
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||
context
|
||||
)
|
||||
@@ -189,25 +171,10 @@ fun App(viewModel: NostrViewModel) {
|
||||
OnboardingScreen()
|
||||
}
|
||||
entry<Screen.Import> {
|
||||
ImportScreen(
|
||||
onSave = { secret ->
|
||||
viewModel.importIdentity(secret)
|
||||
}
|
||||
)
|
||||
ImportScreen()
|
||||
}
|
||||
entry<Screen.NewIdentity> {
|
||||
NewIdentityScreen(
|
||||
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)
|
||||
}
|
||||
)
|
||||
NewIdentityScreen()
|
||||
}
|
||||
entry<Screen.Chat> { key ->
|
||||
ChatScreen(id = key.id)
|
||||
@@ -218,6 +185,9 @@ fun App(viewModel: NostrViewModel) {
|
||||
entry<Screen.Profile> { key ->
|
||||
ProfileScreen(pubkey = key.pubkey)
|
||||
}
|
||||
entry<Screen.UpdateProfile> {
|
||||
UpdateProfileScreen()
|
||||
}
|
||||
entry<Screen.Scan> {
|
||||
ScanScreen()
|
||||
}
|
||||
@@ -229,61 +199,6 @@ fun App(viewModel: NostrViewModel) {
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class CrashActivity : ComponentActivity() {
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val errorText = intent.getStringExtra("error") ?: "Unknown error"
|
||||
|
||||
setContent {
|
||||
MaterialExpressiveTheme {
|
||||
Scaffold(
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
"App Crashed",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Text(
|
||||
"Please copy the log below and send it to the developer.",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Text(
|
||||
text = errorText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
finish();
|
||||
exitProcess(0)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Exit")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
val clipboard =
|
||||
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val data = ClipData.newPlainText("Crash Log", errorText)
|
||||
clipboard.setPrimaryClip(data)
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
|
||||
class ExternalSignerLauncher {
|
||||
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||
|
||||
fun register(launcher: ActivityResultLauncher<Intent>) {
|
||||
this.launcher = launcher
|
||||
}
|
||||
|
||||
suspend fun launch(intent: Intent): ActivityResult {
|
||||
val deferred = CompletableDeferred<ActivityResult>()
|
||||
pendingResult = deferred
|
||||
launcher?.launch(intent)
|
||||
?: throw IllegalStateException("ExternalSignerLauncher not registered")
|
||||
return deferred.await()
|
||||
}
|
||||
|
||||
fun onResult(result: ActivityResult) {
|
||||
pendingResult?.complete(result)
|
||||
pendingResult = null
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,79 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
val externalSignerLauncher = ExternalSignerLauncher()
|
||||
}
|
||||
|
||||
private val viewModel: NostrViewModel by viewModels {
|
||||
object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val secretStore = SecretStore(this@MainActivity)
|
||||
return NostrViewModel(NostrManager.instance, secretStore) as T
|
||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
throwable.printStackTrace()
|
||||
|
||||
Log.e(
|
||||
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
|
||||
)
|
||||
|
||||
// Start the Crash Activity
|
||||
val intent = Intent(this, CrashActivity::class.java).apply {
|
||||
putExtra("error", throwable.stackTraceToString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
|
||||
// Exit
|
||||
Process.killProcess(Process.myPid())
|
||||
exitProcess(1)
|
||||
}
|
||||
|
||||
val resultLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
externalSignerLauncher.onResult(result)
|
||||
}
|
||||
externalSignerLauncher.register(resultLauncher)
|
||||
|
||||
val splashScreen = installSplashScreen()
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(serviceIntent)
|
||||
} else {
|
||||
startService(serviceIntent)
|
||||
}
|
||||
|
||||
// Keep the splash screen visible until the signer check is complete
|
||||
splashScreen.setKeepOnScreenCondition {
|
||||
viewModel.signerRequired.value == null
|
||||
}
|
||||
|
||||
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
||||
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
||||
|
||||
setContent {
|
||||
App(viewModel = viewModel)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ sealed interface Screen : NavKey {
|
||||
@Serializable
|
||||
data class Profile(val pubkey: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data object UpdateProfile : Screen
|
||||
|
||||
@Serializable
|
||||
data object NewChat : Screen
|
||||
|
||||
|
||||
@@ -6,15 +6,17 @@ import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -22,7 +24,8 @@ import java.io.File
|
||||
|
||||
class NostrForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val nostr = NostrManager.instance
|
||||
private val nostr by lazy { NostrManager.instance }
|
||||
private var notificationJob: Job? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
@@ -30,20 +33,36 @@ class NostrForegroundService : Service() {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
createNotificationChannel()
|
||||
val notification = createNotification()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(1, notification)
|
||||
}
|
||||
}
|
||||
|
||||
val notification = createNotification()
|
||||
startForeground(1, notification)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (notificationJob?.isActive == true) return START_STICKY
|
||||
|
||||
serviceScope.launch {
|
||||
notificationJob = serviceScope.launch {
|
||||
try {
|
||||
Log.d("Coop", "Starting Nostr in background")
|
||||
|
||||
// Create a database directory
|
||||
val dbDir = File(filesDir, "nostr")
|
||||
dbDir.mkdirs()
|
||||
|
||||
// Initialize Nostr client
|
||||
try {
|
||||
nostr.init(dbDir.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||
}
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
// Handle notifications
|
||||
@@ -67,14 +86,13 @@ class NostrForegroundService : Service() {
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to start Nostr in background: ${e.message}")
|
||||
Log.e("Coop", "Failed to start Nostr", e)
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createNotificationChannel() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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.IntrinsicSize
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
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.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -47,11 +49,12 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import 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.LocalNavigator
|
||||
@@ -63,7 +66,6 @@ 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) {
|
||||
@@ -71,28 +73,35 @@ fun ChatScreen(id: Long) {
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val chatRooms by viewModel.chatRooms.collectAsState()
|
||||
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } }
|
||||
// Get chat room by ID
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||
|
||||
// Show empty screen
|
||||
if (room == null) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
Text(
|
||||
text = "Something went wrong.",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
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) }
|
||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
|
||||
val groupedMessages = remember(messages.toList()) {
|
||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||
}
|
||||
@@ -101,23 +110,14 @@ fun ChatScreen(id: Long) {
|
||||
// Start loading spinner
|
||||
loading = true
|
||||
|
||||
// Get msg relays for each member
|
||||
viewModel.chatRoomConnect(id)
|
||||
|
||||
// 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
|
||||
loading = false
|
||||
|
||||
@@ -149,7 +149,7 @@ fun ChatScreen(id: Long) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable {
|
||||
room.members.firstOrNull()?.let { pubkey ->
|
||||
room!!.members.firstOrNull()?.let { pubkey ->
|
||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||
}
|
||||
}
|
||||
@@ -235,10 +235,15 @@ fun ChatScreen(id: Long) {
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
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.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -36,6 +47,7 @@ import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
@@ -61,8 +73,14 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
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.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_new_chat
|
||||
import coop.composeapp.generated.resources.ic_qr
|
||||
@@ -85,6 +103,7 @@ import su.reya.coop.short
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen() {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
@@ -94,18 +113,36 @@ fun HomeScreen() {
|
||||
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 userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||
|
||||
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) }
|
||||
|
||||
var isNotificationEnabled by remember {
|
||||
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
||||
}
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { _ ->
|
||||
// State will be updated by LifecycleResumeEffect
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(context) {
|
||||
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getChatRooms()
|
||||
}
|
||||
@@ -187,10 +224,73 @@ fun HomeScreen() {
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "Get message notifications",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSecondaryFixed,
|
||||
)
|
||||
Text(
|
||||
text = "Make sure you know when you have new messages.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { viewModel.dismissNotificationBanner() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Maybe later")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
// For older versions, navigate the user directly to App Notification Settings
|
||||
val intent =
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(
|
||||
Settings.EXTRA_APP_PACKAGE,
|
||||
context.packageName
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(text = "Turn on")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
@@ -262,6 +362,9 @@ fun HomeScreen() {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
modifier = Modifier
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
val pubkey = viewModel.currentUser()
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
@@ -319,7 +422,8 @@ fun HomeScreen() {
|
||||
scope.launch {
|
||||
pubkey?.let {
|
||||
val bech32 = it.toBech32()
|
||||
val data = ClipData.newPlainText(bech32, bech32)
|
||||
val data =
|
||||
ClipData.newPlainText(bech32, bech32)
|
||||
clipboardManager.setClipEntry(ClipEntry(data))
|
||||
}
|
||||
}
|
||||
@@ -346,8 +450,80 @@ fun HomeScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Show the relay setup dialog if the msg relay list is empty
|
||||
if (isRelayListEmpty) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
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))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Retry",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.useDefaultMsgRelayList()
|
||||
sheetState.hide()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Use Default",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@@ -402,9 +578,10 @@ fun BottomMenuList(
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { },
|
||||
"Spams & Blocks" to { },
|
||||
"Contacts" to { },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { }
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
@@ -49,10 +48,11 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Keys
|
||||
@@ -69,32 +69,28 @@ import su.reya.coop.short
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
onSave: (secret: String) -> Unit
|
||||
) {
|
||||
fun ImportScreen() {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val qrScanResult = LocalScanResult.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||
|
||||
// Get metadata when pubkey changes
|
||||
val metadata by remember(pubkey) {
|
||||
if (pubkey != null) {
|
||||
viewModel.getMetadata(pubkey!!)
|
||||
} else {
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
}.collectAsState(null)
|
||||
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
|
||||
}.collectAsStateWithLifecycle(null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||
val picture = profile?.picture
|
||||
|
||||
val isLoading by viewModel.isCreating.collectAsState()
|
||||
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching {
|
||||
@@ -209,6 +205,7 @@ fun ImportScreen(
|
||||
BasicTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
@@ -221,10 +218,10 @@ fun ImportScreen(
|
||||
),
|
||||
visualTransformation = PasswordVisualTransformation('*'),
|
||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (secret.isEmpty()) {
|
||||
@@ -246,24 +243,28 @@ fun ImportScreen(
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (pubkey == null) {
|
||||
scope.launch {
|
||||
if (pubkey == null) {
|
||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
}
|
||||
} else {
|
||||
onSave(secret)
|
||||
// Import the identity
|
||||
viewModel.importIdentity(secret)
|
||||
// Navigate to the home screen
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = secret.isNotBlank() && !isLoading,
|
||||
enabled = secret.isNotBlank() && !isBusy,
|
||||
) {
|
||||
if (isLoading) {
|
||||
if (isBusy) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = if (pubkey == null) "Verify" else "Continue",
|
||||
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,279 +1,31 @@
|
||||
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.imePadding
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.collectAsState
|
||||
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.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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 androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun NewIdentityScreen(
|
||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
||||
) {
|
||||
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val navigator = LocalNavigator.current
|
||||
fun NewIdentityScreen() {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var bio by remember { mutableStateOf("") }
|
||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val isLoading by viewModel.isCreating.collectAsState()
|
||||
|
||||
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 = { navigator.goBack() }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
.imePadding(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = true),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.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(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
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,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
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.size(16.dp))
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ProfileEditor(
|
||||
title = "Create a new identity",
|
||||
buttonLabel = "Continue",
|
||||
isBusy = isBusy,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
viewModel.createIdentity(name, bio, bytes, type)
|
||||
navigator.navigate(Screen.Home)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,13 +14,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -27,6 +32,7 @@ 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.platform.LocalContext
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -34,10 +40,13 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.coop
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.getExpressiveFontFamily
|
||||
@@ -45,11 +54,15 @@ import su.reya.coop.shared.getExpressiveFontFamily
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OnboardingScreen() {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val logoPainter = painterResource(Res.drawable.coop)
|
||||
val expressiveFont = getExpressiveFontFamily()
|
||||
|
||||
val annotatedText = buildAnnotatedString {
|
||||
append("By using Coop, you agree to accept\nour ")
|
||||
// Push "Terms of Use" link
|
||||
@@ -142,7 +155,44 @@ fun OnboardingScreen() {
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
OutlinedButton(
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (viewModel.isExternalSignerAvailable()) {
|
||||
try {
|
||||
viewModel.connectExternalSigner()
|
||||
navigator.navigate(Screen.Home)
|
||||
} catch (e: Exception) {
|
||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||
}
|
||||
} else {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "External signer not installed. Please install Amber or alternatives.",
|
||||
actionLabel = "Install",
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) {
|
||||
val intent = Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
"https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
|
||||
)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Connect with Amber",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
TextButton(
|
||||
onClick = { navigator.navigate(Screen.Import) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -176,7 +176,7 @@ fun ProfileScreen(pubkey: String) {
|
||||
}
|
||||
Text(
|
||||
text = "Message",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
Column(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.shared.ProfileEditor
|
||||
|
||||
@Composable
|
||||
fun UpdateProfileScreen() {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val navigator = LocalNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
|
||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
|
||||
ProfileEditor(
|
||||
title = "Update profile",
|
||||
buttonLabel = "Save changes",
|
||||
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||
initialBio = profile?.about ?: "",
|
||||
initialPicture = profile?.picture,
|
||||
isBusy = isBusy,
|
||||
onBack = { navigator.goBack() },
|
||||
onConfirm = { name, bio, bytes, type ->
|
||||
scope.launch {
|
||||
viewModel.updateProfile(name, bio, bytes, type)
|
||||
navigator.goBack()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
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.imePadding
|
||||
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.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileEditor(
|
||||
title: String,
|
||||
buttonLabel: String,
|
||||
initialName: String = "",
|
||||
initialBio: String = "",
|
||||
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||
isBusy: Boolean = false,
|
||||
onBack: () -> Unit,
|
||||
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||
|
||||
val hasPicture = remember(picture) {
|
||||
when (picture) {
|
||||
null -> false
|
||||
is String -> (picture as CharSequence).isNotBlank()
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
picture = uri
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title, style = MaterialTheme.typography.titleMediumEmphasized) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding())
|
||||
.imePadding(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Pentagon.toShape())
|
||||
.clickable { launcher.launch("image/*") },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasPicture) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
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.onTertiaryFixed
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, fill = true),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.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 },
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||
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 },
|
||||
enabled = !isBusy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
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.size(16.dp))
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(ButtonDefaults.MediumContainerHeight),
|
||||
onClick = {
|
||||
val scope = CoroutineScope(Dispatchers.Main)
|
||||
scope.launch {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
(picture as? Uri)?.let {
|
||||
context.contentResolver.openInputStream(it)?.readBytes()
|
||||
}
|
||||
}
|
||||
val type =
|
||||
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||
onConfirm(name, bio, bytes, type)
|
||||
}
|
||||
},
|
||||
enabled = name.isNotBlank() && !isBusy
|
||||
) {
|
||||
if (isBusy) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = buttonLabel,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,25 +9,32 @@ import su.reya.coop.Room
|
||||
import su.reya.coop.short
|
||||
|
||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
|
||||
// Return early if there's a custom subject/room name
|
||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||
|
||||
val memberFlows = members.map { viewModel.getMetadata(it) }
|
||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||
|
||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
||||
val names = metadataArray.mapIndexed { i, metadata ->
|
||||
val profile = metadata?.asRecord()
|
||||
profile?.name?.takeIf { it.isNotBlank() }
|
||||
?: profile?.displayName?.takeIf { it.isNotBlank() }
|
||||
?: displayMembers[i].short()
|
||||
}
|
||||
|
||||
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" }
|
||||
val combined = names.joinToString(", ")
|
||||
val extraCount = members.size - names.size
|
||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
||||
} else {
|
||||
val profile = metadataArray.firstOrNull()?.asRecord()
|
||||
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
|
||||
val name = names.first()
|
||||
if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
||||
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
|
||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
[versions]
|
||||
agp = "9.2.1"
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "24"
|
||||
android-targetSdk = "36"
|
||||
android-compileSdk = "37"
|
||||
android-minSdk = "26"
|
||||
android-targetSdk = "37"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-appcompat = "1.7.1"
|
||||
androidx-core = "1.18.0"
|
||||
|
||||
@@ -30,9 +30,10 @@ kotlin {
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.6")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
}
|
||||
androidMain.dependencies {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
|
||||
/**
|
||||
* Platform interface for NIP-55 external signer communication.
|
||||
* Implemented on Android; no-op/null on other platforms.
|
||||
*/
|
||||
interface ExternalSignerHandler {
|
||||
fun isAvailable(): Boolean
|
||||
fun setPackageName(packageName: String)
|
||||
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
|
||||
suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
|
||||
suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
|
||||
suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
|
||||
suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
|
||||
suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SignerPermission(
|
||||
val type: String,
|
||||
val kind: Int? = null,
|
||||
)
|
||||
|
||||
object SignerPermissions {
|
||||
fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
|
||||
fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
|
||||
fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
|
||||
fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
|
||||
fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
|
||||
|
||||
fun toJson(permissions: List<SignerPermission>): String {
|
||||
return Json.encodeToString(permissions)
|
||||
}
|
||||
}
|
||||
|
||||
data class ExternalSignerResult(
|
||||
val pubkey: PublicKey,
|
||||
val packageName: String,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
package su.reya.coop
|
||||
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
|
||||
class ExternalSignerProxy(
|
||||
private val handler: ExternalSignerHandler,
|
||||
private val currentUser: PublicKey,
|
||||
) : AsyncNostrSigner {
|
||||
override suspend fun getPublicKeyAsync(): PublicKey {
|
||||
return currentUser
|
||||
}
|
||||
|
||||
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
|
||||
val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
|
||||
return Event.fromJson(signedJson)
|
||||
}
|
||||
|
||||
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||
return handler.nip04Encrypt(content, publicKey)
|
||||
?: throw Exception("NIP-04 encrypt rejected")
|
||||
}
|
||||
|
||||
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
|
||||
return handler.nip04Decrypt(encryptedContent, publicKey)
|
||||
?: throw Exception("NIP-04 decrypt rejected")
|
||||
}
|
||||
|
||||
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||
return handler.nip44Encrypt(content, publicKey, currentUser)
|
||||
?: throw Exception("NIP-44 encrypt rejected")
|
||||
}
|
||||
|
||||
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||
return handler.nip44Decrypt(payload, publicKey, currentUser)
|
||||
?: throw Exception("NIP-44 decrypt rejected")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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
|
||||
@@ -39,26 +38,39 @@ import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayCapabilities
|
||||
import rust.nostr.sdk.RelayMessageEnum
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayStatus
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.ReqExitPolicy
|
||||
import rust.nostr.sdk.ReqTarget
|
||||
import rust.nostr.sdk.SendEventTarget
|
||||
import rust.nostr.sdk.SignerAuthenticator
|
||||
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 rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object NostrManager {
|
||||
val instance = Nostr()
|
||||
|
||||
val BOOTSTRAP_RELAYS = listOf(
|
||||
"wss://relay.primal.net",
|
||||
"wss://purplepag.es"
|
||||
)
|
||||
|
||||
val INDEXER_RELAY = listOf(
|
||||
"wss://indexer.coracle.social",
|
||||
)
|
||||
|
||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||
}
|
||||
|
||||
class Nostr {
|
||||
@@ -75,7 +87,6 @@ class Nostr {
|
||||
|
||||
private val isInitialized = MutableStateFlow(false)
|
||||
|
||||
// Add these to the Nostr class
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
@@ -99,35 +110,33 @@ class Nostr {
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||
_contactListUpdates.emit(contacts)
|
||||
|
||||
suspend fun init(dbPath: String) {
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
logLevel: LogLevel = LogLevel.WARN
|
||||
) {
|
||||
try {
|
||||
if (isInitialized.value) return
|
||||
|
||||
// Initialize the logger for nostr client
|
||||
initLogger(LogLevel.DEBUG)
|
||||
initLogger(logLevel)
|
||||
|
||||
// Initialize configurations for nostr client
|
||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||
val gossip = NostrGossip.inMemory()
|
||||
val authenticator = SignerAuthenticator(signer)
|
||||
val idleTimeout = Duration.parse("5m")
|
||||
val httpClient = HttpClient {
|
||||
install(WebSockets)
|
||||
}
|
||||
|
||||
client =
|
||||
ClientBuilder()
|
||||
.signer(signer)
|
||||
.websocketTransport(CoopWebSocketClient(httpClient))
|
||||
.authenticator(authenticator)
|
||||
.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()
|
||||
|
||||
@@ -142,24 +151,43 @@ class Nostr {
|
||||
}
|
||||
|
||||
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
|
||||
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||
client?.addRelay(RelayUrl.parse(url))
|
||||
}
|
||||
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||
client?.addRelay(
|
||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
||||
url = RelayUrl.parse(url),
|
||||
capabilities = RelayCapabilities.gossip()
|
||||
)
|
||||
}
|
||||
// Connect to all bootstrap relays
|
||||
client?.connect()
|
||||
}
|
||||
|
||||
// Connect to all bootstrap relays and wait for all connections to be established
|
||||
client?.connect(Duration.parse("2s"))
|
||||
suspend fun reconnect() {
|
||||
NostrManager.ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||
if (relay != null) {
|
||||
if (relay.status() != RelayStatus.CONNECTED) {
|
||||
relay.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Failed to reconnect relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
client?.shutdown()
|
||||
NostrManager.ALL_RELAYS.forEach { url ->
|
||||
try {
|
||||
client?.disconnectRelay(RelayUrl.parse(url))
|
||||
} catch (e: Exception) {
|
||||
println("Failed to disconnect relay: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exit() {
|
||||
@@ -230,7 +258,7 @@ class Nostr {
|
||||
|
||||
client?.subscribe(
|
||||
target = ReqTarget.manual(target),
|
||||
id = "all-gift-wraps"
|
||||
id = "gift-wraps"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||
@@ -295,7 +323,7 @@ class Nostr {
|
||||
eoseTrackerJob?.cancel()
|
||||
// Start a new tracker
|
||||
eoseTrackerJob = launch {
|
||||
delay(10000) // Wait for 10 seconds
|
||||
delay(10000.milliseconds) // Wait for 10 seconds
|
||||
onSubscriptionClose()
|
||||
}
|
||||
|
||||
@@ -314,7 +342,7 @@ class Nostr {
|
||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||
val subscriptionId = message.subscriptionId
|
||||
|
||||
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
|
||||
if (subscriptionId == "gift-wraps") {
|
||||
onSubscriptionClose()
|
||||
}
|
||||
}
|
||||
@@ -359,16 +387,15 @@ class Nostr {
|
||||
val currentUser =
|
||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
// Ensure the rumor ID is set
|
||||
val rumor = rumor.ensureId()
|
||||
// Construct the room id
|
||||
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"))
|
||||
Tag.custom("a", listOf(roomId.toString())),
|
||||
Tag.custom("k", listOf("14"))
|
||||
)
|
||||
|
||||
// Set event kind
|
||||
@@ -376,8 +403,8 @@ class Nostr {
|
||||
|
||||
val event = EventBuilder(kind, rumor.asJson())
|
||||
.tags(tags)
|
||||
.build(currentUser)
|
||||
.signWithKeys(Keys.generate())
|
||||
.finalizeUnsigned(currentUser)
|
||||
.signAsync(Keys.generate())
|
||||
|
||||
client?.database()?.saveEvent(event)
|
||||
} catch (e: Exception) {
|
||||
@@ -397,7 +424,6 @@ class Nostr {
|
||||
// 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
|
||||
@@ -427,9 +453,10 @@ class Nostr {
|
||||
client?.addRelay(
|
||||
url = relay,
|
||||
capabilities =
|
||||
if (metadata == RelayMetadata.READ) RelayCapabilities.read()
|
||||
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
|
||||
else RelayCapabilities.none()
|
||||
when (metadata) {
|
||||
RelayMetadata.READ -> RelayCapabilities.read()
|
||||
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||
}
|
||||
)
|
||||
client?.connectRelay(relay)
|
||||
}
|
||||
@@ -440,7 +467,7 @@ class Nostr {
|
||||
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||
// Construct a list of messaging relays
|
||||
val msgRelayList = listOf(
|
||||
RelayUrl.parse("wss://relay.0xchat.com"),
|
||||
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||
RelayUrl.parse("wss://nip17.com"),
|
||||
)
|
||||
|
||||
@@ -456,7 +483,7 @@ class Nostr {
|
||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||
// Send relay list event
|
||||
val relayList = getDefaultRelayList()
|
||||
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
|
||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
||||
|
||||
client?.sendEvent(
|
||||
event = relayListEvent,
|
||||
@@ -467,7 +494,7 @@ class Nostr {
|
||||
|
||||
// Send messaging relay list event
|
||||
val msgRelayList = getDefaultMsgRelayList()
|
||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = msgRelayListEvent,
|
||||
@@ -478,7 +505,7 @@ class Nostr {
|
||||
// Send metadata event
|
||||
val metadata =
|
||||
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
||||
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
|
||||
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = metadataEvent,
|
||||
@@ -488,8 +515,8 @@ class Nostr {
|
||||
|
||||
// Send contact list event
|
||||
val defaultContact =
|
||||
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
|
||||
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
|
||||
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = contactListEvent,
|
||||
@@ -500,20 +527,67 @@ class Nostr {
|
||||
setSigner(keys)
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: String? = null
|
||||
): Metadata {
|
||||
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||
|
||||
try {
|
||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||
val newRecord = record.copy(
|
||||
displayName = name ?: record.displayName,
|
||||
about = bio ?: record.about,
|
||||
picture = picture ?: record.picture
|
||||
)
|
||||
val newMetadata = Metadata.fromRecord(newRecord)
|
||||
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
target = SendEventTarget.broadcast(),
|
||||
ackPolicy = AckPolicy.none()
|
||||
)
|
||||
|
||||
return newMetadata
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||
return try {
|
||||
val kind = Kind.fromStd(KindStandard.METADATA);
|
||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||
|
||||
Metadata.fromJson(event.content())
|
||||
} catch (e: Exception) {
|
||||
println("Failed to get latest metadata: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||
try {
|
||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
|
||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||
val events = client?.database()?.query(filter)
|
||||
val results = mutableMapOf<PublicKey, Metadata>()
|
||||
|
||||
events?.toVec()?.forEach { event ->
|
||||
try {
|
||||
val metadata = Metadata.fromJson(event.content())
|
||||
results[event.author()] = metadata
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse metadata: $e")
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
|
||||
println("Failed to get all cache metadata: ${e.message}")
|
||||
return emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,7 +607,6 @@ class Nostr {
|
||||
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),
|
||||
)
|
||||
)
|
||||
@@ -546,7 +619,7 @@ class Nostr {
|
||||
|
||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||
try {
|
||||
val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
|
||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||
|
||||
client?.sendEvent(
|
||||
event = event,
|
||||
@@ -596,7 +669,7 @@ class Nostr {
|
||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||
|
||||
// Get all events sent by the user
|
||||
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "14")
|
||||
val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm"))
|
||||
val events = client?.database()?.query(filter)
|
||||
|
||||
// Collect rooms
|
||||
@@ -641,39 +714,33 @@ class Nostr {
|
||||
return events
|
||||
?.toVec()
|
||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||
// Filter out events without public keys (receivers)
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.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>> {
|
||||
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||
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)}",
|
||||
id = null,
|
||||
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)
|
||||
}
|
||||
@@ -683,18 +750,16 @@ class Nostr {
|
||||
try {
|
||||
val urls = nip17ExtractRelayList(event);
|
||||
for (url in urls) {
|
||||
if (client?.relay(url) == null) {
|
||||
client?.addRelay(url)
|
||||
client?.addRelay(url, RelayCapabilities.gossip())
|
||||
client?.connectRelay(url)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendMessage(
|
||||
to: List<PublicKey>,
|
||||
to: Set<PublicKey>,
|
||||
content: String,
|
||||
subject: String? = null,
|
||||
replies: List<EventId> = emptyList(),
|
||||
@@ -708,7 +773,7 @@ class Nostr {
|
||||
|
||||
// Add a subject tag if provided
|
||||
if (subject != null) {
|
||||
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
|
||||
tags.add(Tag.custom("subject", listOf(subject)))
|
||||
}
|
||||
|
||||
// Add event tags for replies
|
||||
@@ -720,20 +785,15 @@ class Nostr {
|
||||
|
||||
// Add public key tags for each recipient
|
||||
to.forEach { pubkey ->
|
||||
if (pubkey != currentUser) {
|
||||
tags.add(Tag.publicKey(pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
for (receiver in listOf(currentUser) + to) {
|
||||
for (receiver in setOf(currentUser) + to) {
|
||||
// Construct the rumor event
|
||||
// NEVER SIGN this event with the current user signer
|
||||
val rumor = EventBuilder
|
||||
.privateMsgRumor(receiver = receiver, message = content)
|
||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||
.tags(tags)
|
||||
.build(currentUser)
|
||||
// Ensure the event ID is set
|
||||
.ensureId()
|
||||
.finalizeUnsigned(currentUser)
|
||||
|
||||
// Emit the rumor to the chat screen
|
||||
if (receiver == currentUser) {
|
||||
@@ -741,12 +801,12 @@ class Nostr {
|
||||
}
|
||||
|
||||
// Construct the gift wrap event
|
||||
val gift = giftWrapAsync(
|
||||
val gift = nip59MakeGiftWrapAsync(
|
||||
signer = signer,
|
||||
receiverPubkey = receiver,
|
||||
rumor = rumor,
|
||||
extraTags = listOf(
|
||||
Tag.custom(TagKind.Unknown("k"), listOf("14"))
|
||||
Tag.custom("k", listOf("14"))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -807,13 +867,12 @@ class Nostr {
|
||||
|
||||
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 target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||
|
||||
val stream = client?.streamEvents(
|
||||
target = target,
|
||||
id = "search",
|
||||
timeout = Duration.parse("4s"),
|
||||
timeout = Duration.parse("3s"),
|
||||
policy = ReqExitPolicy.ExitOnEose
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
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.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -23,6 +26,8 @@ import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
@@ -34,23 +39,22 @@ 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.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class NostrViewModel(
|
||||
private val nostr: Nostr,
|
||||
private val secretStore: SecretStorage
|
||||
private val secretStore: SecretStorage,
|
||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||
) : ViewModel() {
|
||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||
|
||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||
val signerRequired = _signerRequired.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 _isBusy = MutableStateFlow(false)
|
||||
val isBusy = _isBusy.asStateFlow()
|
||||
|
||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||
@@ -58,10 +62,16 @@ class NostrViewModel(
|
||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||
val isRelayListEmpty = _isRelayListEmpty.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 _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
|
||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||
val sentReport = _sentReports.asSharedFlow()
|
||||
|
||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||
@@ -72,25 +82,43 @@ class NostrViewModel(
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
init {
|
||||
// Skip the splash screen if a user is already logged in
|
||||
if (nostr.signer.currentUser != null) {
|
||||
_signerRequired.value = false
|
||||
}
|
||||
|
||||
// Check if the notification banner has been dismissed
|
||||
checkNotificationBannerDismissedStatus()
|
||||
|
||||
// Check local stored secret (secret key or bunker)
|
||||
login()
|
||||
|
||||
// Automatically reconnect bootstrap relays
|
||||
reconnect()
|
||||
|
||||
// Observe the signer state and verify the relay list
|
||||
observeSignerAndCheckRelays()
|
||||
|
||||
// Get all local stored metadata
|
||||
getCacheMetadata()
|
||||
}
|
||||
|
||||
// Observe new events from the Nostr client
|
||||
runObserver()
|
||||
|
||||
// Wait and merge metadata requests into a single batch
|
||||
runMetadataBatching()
|
||||
fun bindLifecycle(lifecycle: Lifecycle) {
|
||||
viewModelScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
coroutineScope {
|
||||
launch { refreshChatRooms() }
|
||||
launch { runObserver() }
|
||||
launch { runMetadataBatching() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Ensure all relays are disconnect
|
||||
|
||||
// Disconnect to all bootstrap relays
|
||||
viewModelScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
nostr.disconnect()
|
||||
@@ -101,12 +129,38 @@ class NostrViewModel(
|
||||
private fun showError(message: String) {
|
||||
viewModelScope.launch {
|
||||
_errorEvents.send(message)
|
||||
if (isCreating.value) _isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun runObserver() {
|
||||
private fun checkNotificationBannerDismissedStatus() {
|
||||
viewModelScope.launch {
|
||||
_isNotificationBannerDismissed.value =
|
||||
secretStore.get("notification_banner_dismissed") == "true"
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconnect() {
|
||||
viewModelScope.launch {
|
||||
nostr.waitUntilInitialized()
|
||||
nostr.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun processIncomingEvent(event: UnsignedEvent) {
|
||||
val roomId = event.roomId()
|
||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||
|
||||
if (existingRoom == null) {
|
||||
nostr.signer.currentUser?.let { user ->
|
||||
val newRoom = Room.new(event, user)
|
||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||
}
|
||||
} else {
|
||||
updateRoomList(roomId, event)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runObserver() = coroutineScope {
|
||||
// Observe new messages
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
@@ -127,13 +181,6 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Observe metadata updates
|
||||
launch {
|
||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Observe contact list updates
|
||||
launch {
|
||||
nostr.contactListUpdates.collect { contacts ->
|
||||
@@ -141,6 +188,13 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Observe metadata updates
|
||||
launch {
|
||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Observes subscription close
|
||||
launch {
|
||||
nostr.subscriptionClosed.collect {
|
||||
@@ -149,10 +203,8 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMetadataBatching() {
|
||||
viewModelScope.launch {
|
||||
private suspend fun runMetadataBatching() = coroutineScope {
|
||||
// Wait until the client is ready
|
||||
nostr.waitUntilInitialized()
|
||||
|
||||
@@ -165,15 +217,17 @@ class NostrViewModel(
|
||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
while (batch.isNotEmpty()) {
|
||||
val nextKey = withTimeoutOrNull(timeout) {
|
||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||
metadataRequestChannel.receive()
|
||||
}
|
||||
|
||||
if (nextKey != null) {
|
||||
batch.add(nextKey)
|
||||
}
|
||||
// Only add the key if it's not null
|
||||
if (nextKey != null) batch.add(nextKey)
|
||||
|
||||
// Get current time
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
// Check if the batch is full or timeout has passed
|
||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||
val keysToRequest = batch.toList()
|
||||
batch.clear()
|
||||
@@ -183,7 +237,6 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCacheMetadata() {
|
||||
viewModelScope.launch {
|
||||
@@ -203,7 +256,9 @@ class NostrViewModel(
|
||||
private fun login() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val secret = secretStore.get("user_signer")
|
||||
val secret = withTimeoutOrNull(3.seconds) {
|
||||
secretStore.get("user_signer")
|
||||
}
|
||||
|
||||
if (secret == null) {
|
||||
_signerRequired.value = true
|
||||
@@ -235,7 +290,7 @@ class NostrViewModel(
|
||||
// Get chat rooms
|
||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||
if (rooms.isNotEmpty()) {
|
||||
_chatRooms.value = rooms
|
||||
mergeChatRooms(rooms)
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
|
||||
@@ -243,7 +298,7 @@ class NostrViewModel(
|
||||
nostr.getUserMetadata()
|
||||
|
||||
// Small delay to ensure all relays are connected
|
||||
delay(3000)
|
||||
delay(3000.milliseconds)
|
||||
|
||||
// Check if the relay list is empty
|
||||
val relays = nostr.getMsgRelays(pubkey)
|
||||
@@ -254,7 +309,7 @@ class NostrViewModel(
|
||||
break
|
||||
}
|
||||
|
||||
delay(500)
|
||||
delay(500.milliseconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +346,13 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotificationBanner() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set("notification_banner_dismissed", "true")
|
||||
_isNotificationBannerDismissed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissRelayWarning() {
|
||||
_isRelayListEmpty.value = false
|
||||
}
|
||||
@@ -310,37 +372,9 @@ class NostrViewModel(
|
||||
return keys
|
||||
}
|
||||
|
||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||
return when {
|
||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||
secret.startsWith("bunker://") -> {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val timeout = 50.seconds // or Duration.parse("50s")
|
||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid secret format")
|
||||
}
|
||||
}
|
||||
|
||||
fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
|
||||
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 {
|
||||
@@ -355,50 +389,155 @@ class NostrViewModel(
|
||||
)
|
||||
|
||||
val descriptor = blossom.upload(
|
||||
file = picture,
|
||||
file = file,
|
||||
contentType = contentType,
|
||||
signer = keys
|
||||
signer = nostr.signer.get()
|
||||
)
|
||||
|
||||
avatarUrl = descriptor?.url ?: ""
|
||||
return descriptor?.url
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateProfile(
|
||||
name: String? = null,
|
||||
bio: String? = null,
|
||||
picture: ByteArray? = null,
|
||||
contentType: String? = null
|
||||
) {
|
||||
_isBusy.value = true
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
|
||||
// Update the metadata state after successfully published
|
||||
updateMetadata(nostr.signer.currentUser!!, newMetadata)
|
||||
// Update local state
|
||||
_isBusy.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createIdentity(
|
||||
name: String,
|
||||
bio: String?,
|
||||
picture: ByteArray?,
|
||||
contentType: String? = null
|
||||
) {
|
||||
_isBusy.value = true
|
||||
|
||||
val keys = Keys.generate()
|
||||
val secret = keys.secretKey().toBech32()
|
||||
|
||||
try {
|
||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||
// Create identity
|
||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||
|
||||
// Save secret to the secret storage
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set("user_signer", secret)
|
||||
|
||||
// Set an empty secret state
|
||||
// Update local states
|
||||
_isBusy.value = false
|
||||
_signerRequired.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||
return when {
|
||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||
|
||||
secret.startsWith("bunker://") -> {
|
||||
val appKeys = getOrInitAppKeys()
|
||||
val bunker = NostrConnectUri.parse(secret)
|
||||
val timeout = 50.seconds // or Duration.parse("50s")
|
||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||
}
|
||||
|
||||
secret.startsWith("nip55://") -> {
|
||||
val handler = externalSignerHandler
|
||||
?: throw IllegalStateException("External signer not available on this platform")
|
||||
|
||||
// Format: nip55://packageName/hexPubkey
|
||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||
val packageName = parts[0]
|
||||
val pubkey = PublicKey.parse(parts[1])
|
||||
|
||||
handler.setPackageName(packageName)
|
||||
ExternalSignerProxy(handler, pubkey)
|
||||
}
|
||||
|
||||
else -> throw IllegalArgumentException("Invalid secret format")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||
return runCatching {
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
if (secret.startsWith("bunker://")) {
|
||||
showError("Please approve the connection.")
|
||||
}
|
||||
signer.getPublicKeyAsync()
|
||||
}.getOrNull()
|
||||
return signer.getPublicKeyAsync()
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun importIdentity(secret: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
suspend fun importIdentity(secret: String) {
|
||||
_isBusy.value = true
|
||||
try {
|
||||
val signer = createSigner(secret)
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Persist the secret in the secret storage
|
||||
secretStore.set("user_signer", secret)
|
||||
}.onSuccess {
|
||||
// Update local states
|
||||
_signerRequired.value = false
|
||||
}.onFailure { e ->
|
||||
showError(e.message ?: "Invalid Secret or Bunker URI")
|
||||
_isBusy.value = false
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun connectExternalSigner() {
|
||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||
_isBusy.value = true
|
||||
try {
|
||||
val permissions = SignerPermissions.toJson(
|
||||
listOf(
|
||||
SignerPermissions.signEvent(0),
|
||||
SignerPermissions.signEvent(3),
|
||||
SignerPermissions.signEvent(10000),
|
||||
SignerPermissions.signEvent(10050),
|
||||
SignerPermissions.signEvent(10063),
|
||||
SignerPermissions.signEvent(22242),
|
||||
SignerPermissions.signEvent(30030),
|
||||
SignerPermissions.signEvent(30315),
|
||||
SignerPermissions.nip44Encrypt(),
|
||||
SignerPermissions.nip44Decrypt(),
|
||||
)
|
||||
)
|
||||
|
||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||
|
||||
// Update signer
|
||||
nostr.setSigner(signer)
|
||||
// Store the signer in the secret storage
|
||||
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
||||
// Update local states
|
||||
_signerRequired.value = false
|
||||
_isBusy.value = false
|
||||
} catch (e: Exception) {
|
||||
throw Exception("Notice: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun isExternalSignerAvailable(): Boolean {
|
||||
return externalSignerHandler?.isAvailable() == true
|
||||
}
|
||||
|
||||
suspend fun useDefaultMsgRelayList() {
|
||||
@@ -436,10 +575,9 @@ class NostrViewModel(
|
||||
val currentUser = nostr.signer.currentUser!!
|
||||
|
||||
// Construct the rumor event
|
||||
val rumor = EventBuilder
|
||||
.privateMsgRumor(to.first(), "")
|
||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||
.tags(to.map { Tag.publicKey(it) })
|
||||
.build(currentUser)
|
||||
.finalizeUnsigned(currentUser)
|
||||
|
||||
// Check if the room already exists
|
||||
val id = rumor.roomId()
|
||||
@@ -455,7 +593,7 @@ class NostrViewModel(
|
||||
|
||||
// Update the chat rooms state
|
||||
_chatRooms.update { currentRooms ->
|
||||
currentRooms + room
|
||||
(currentRooms + room).sortedDescending().toSet()
|
||||
}
|
||||
|
||||
return room.id
|
||||
@@ -464,26 +602,33 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRoom(id: Long): Room {
|
||||
fun getChatRoom(id: Long): Room? {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
?: throw IllegalArgumentException("Room not found")
|
||||
}
|
||||
|
||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||
_chatRooms.update { currentRooms ->
|
||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||
// Add or update rooms from the database
|
||||
rooms.forEach { room ->
|
||||
merged[room.id] = room
|
||||
}
|
||||
// Return as a sorted set to maintain UI consistency
|
||||
merged.values.sortedDescending().toSet()
|
||||
}
|
||||
}
|
||||
|
||||
fun getChatRooms() {
|
||||
viewModelScope.launch {
|
||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||
_chatRooms.update { currentRooms ->
|
||||
val virtualRooms = currentRooms.filter { local ->
|
||||
rooms.none { db -> db.id == local.id }
|
||||
}
|
||||
rooms + virtualRooms
|
||||
}
|
||||
mergeChatRooms(rooms)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshChatRooms() {
|
||||
try {
|
||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||
mergeChatRooms(rooms)
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
}
|
||||
@@ -499,15 +644,16 @@ class NostrViewModel(
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
||||
val room = getChatRoom(roomId)
|
||||
fun chatRoomConnect(roomId: Long) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
val members = room.members
|
||||
|
||||
return runCatching {
|
||||
nostr.chatRoomConnect(members.toList())
|
||||
}.getOrElse { e ->
|
||||
} catch (e: Exception) {
|
||||
showError("Error: ${e.message}")
|
||||
members.associateWith { emptyList<RelayUrl>() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,9 +663,9 @@ class NostrViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val room = getChatRoom(roomId)
|
||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||
nostr.sendMessage(
|
||||
to = room.members.toList(),
|
||||
to = room.members,
|
||||
content = message,
|
||||
subject = room.subject,
|
||||
replies = replies,
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -37,7 +36,7 @@ data class Room(
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||
val id = rumor.roomId()
|
||||
val createdAt = rumor.createdAt()
|
||||
val subject = rumor.tags().find(TagKind.Subject)?.content()
|
||||
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||
|
||||
// Collect the author's public key and all public keys from tags
|
||||
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
||||
|
||||
@@ -2,31 +2,42 @@ package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var signer: AsyncNostrSigner = initialSigner
|
||||
|
||||
@Volatile
|
||||
var currentUser: PublicKey? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Get the current signer.
|
||||
*/
|
||||
suspend fun get(): AsyncNostrSigner = mutex.withLock {
|
||||
signer
|
||||
}
|
||||
fun get(): AsyncNostrSigner = signer
|
||||
|
||||
/**
|
||||
* Switch to a new signer.
|
||||
*/
|
||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
||||
val pubkey = try {
|
||||
withTimeoutOrNull(20.seconds) {
|
||||
newSigner.getPublicKeyAsync()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to get public key from signer", e)
|
||||
}
|
||||
signer = newSigner
|
||||
currentUser = newSigner.getPublicKeyAsync()
|
||||
currentUser = pubkey
|
||||
}
|
||||
|
||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||
|
||||
@@ -75,7 +75,7 @@ class BlossomClient(
|
||||
signer: AsyncNostrSigner,
|
||||
authz: BlossomAuthorization
|
||||
): HeaderValue {
|
||||
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
|
||||
val authEvent = EventBuilder.blossomAuth(authz).finalizeAsync(signer)
|
||||
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
||||
val value = "Nostr $encodedAuth"
|
||||
return HeaderValue(value)
|
||||
|
||||
Reference in New Issue
Block a user