Compare commits
14 Commits
v0.1.6
...
feat/reque
| Author | SHA1 | Date | |
|---|---|---|---|
| bd3b2a94b8 | |||
| 627562f11f | |||
| ea90a43909 | |||
| 938b192136 | |||
| 1f966de654 | |||
| 00821a864b | |||
| 28550f8e25 | |||
| a759ad48e4 | |||
| 0d6b92b0c7 | |||
| 6a69d3a5b2 | |||
| 50b7f7a3f3 | |||
| a65aa70a55 | |||
| 74a37320fe | |||
| b8b3b83952 |
@@ -24,7 +24,7 @@ kotlin {
|
|||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.6"
|
versionName = "0.1.9"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -51,7 +59,7 @@
|
|||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="remoteMessaging" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
|
||||||
|
</vector>
|
||||||
@@ -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 if (resultKey == "event") it.event else 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,50 +2,31 @@ package su.reya.coop
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.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.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.MotionScheme
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.expressiveLightColorScheme
|
import androidx.compose.material3.expressiveLightColorScheme
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.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.core.util.Consumer
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
@@ -53,8 +34,8 @@ import androidx.navigation3.runtime.entryProvider
|
|||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
import su.reya.coop.screens.MyQrScreen
|
import su.reya.coop.screens.MyQrScreen
|
||||||
@@ -63,7 +44,9 @@ import su.reya.coop.screens.NewIdentityScreen
|
|||||||
import su.reya.coop.screens.OnboardingScreen
|
import su.reya.coop.screens.OnboardingScreen
|
||||||
import su.reya.coop.screens.ProfileScreen
|
import su.reya.coop.screens.ProfileScreen
|
||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
|
|
||||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||||
error("No NostrViewModel provided")
|
error("No NostrViewModel provided")
|
||||||
@@ -86,14 +69,11 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
fun App(viewModel: NostrViewModel) {
|
fun App(viewModel: NostrViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val navigator = remember(backStack) { Navigator(backStack) }
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
|
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
||||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -104,7 +84,7 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// 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(
|
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||||
context
|
context
|
||||||
)
|
)
|
||||||
@@ -185,6 +165,9 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen()
|
HomeScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.RequestList> {
|
||||||
|
RequestListScreen()
|
||||||
|
}
|
||||||
entry<Screen.Onboarding> {
|
entry<Screen.Onboarding> {
|
||||||
OnboardingScreen()
|
OnboardingScreen()
|
||||||
}
|
}
|
||||||
@@ -203,72 +186,23 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
entry<Screen.Profile> { key ->
|
entry<Screen.Profile> { key ->
|
||||||
ProfileScreen(pubkey = key.pubkey)
|
ProfileScreen(pubkey = key.pubkey)
|
||||||
}
|
}
|
||||||
|
entry<Screen.UpdateProfile> {
|
||||||
|
UpdateProfileScreen()
|
||||||
|
}
|
||||||
entry<Screen.Scan> {
|
entry<Screen.Scan> {
|
||||||
ScanScreen()
|
ScanScreen()
|
||||||
}
|
}
|
||||||
entry<Screen.MyQr> {
|
entry<Screen.MyQr> {
|
||||||
MyQrScreen()
|
MyQrScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.ContactList> {
|
||||||
|
ContactListScreen()
|
||||||
|
}
|
||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen()
|
RelayScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class ExternalSignerLauncher {
|
||||||
|
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||||
|
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
fun register(launcher: ActivityResultLauncher<Intent>) {
|
||||||
|
this.launcher = launcher
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val deferred = CompletableDeferred<ActivityResult>()
|
||||||
|
pendingResult = deferred
|
||||||
|
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun onResult(result: ActivityResult) {
|
||||||
|
pendingResult?.complete(result)
|
||||||
|
pendingResult = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,32 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Process
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
companion object {
|
||||||
|
val externalSignerLauncher = ExternalSignerLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
private val viewModel: NostrViewModel by viewModels {
|
private val viewModel: NostrViewModel by viewModels {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
val secretStore = SecretStore(this@MainActivity)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,10 +34,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
throwable.printStackTrace()
|
throwable.printStackTrace()
|
||||||
android.util.Log.e(
|
|
||||||
"CoopCrash",
|
Log.e(
|
||||||
"Uncaught exception in thread ${thread.name}",
|
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
|
||||||
throwable
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start the Crash Activity
|
// Start the Crash Activity
|
||||||
@@ -40,28 +47,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
|
||||||
// Exit
|
// Exit
|
||||||
android.os.Process.killProcess(android.os.Process.myPid())
|
Process.killProcess(Process.myPid())
|
||||||
exitProcess(1)
|
exitProcess(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val resultLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
externalSignerLauncher.onResult(result)
|
||||||
|
}
|
||||||
|
externalSignerLauncher.register(resultLauncher)
|
||||||
|
|
||||||
val splashScreen = installSplashScreen()
|
val splashScreen = installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(serviceIntent)
|
startForegroundService(serviceIntent)
|
||||||
} else {
|
|
||||||
startService(serviceIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
viewModel.signerRequired.value == null
|
viewModel.signerRequired.value == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
||||||
|
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(viewModel = viewModel)
|
App(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,21 @@ sealed interface Screen : NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object RequestList : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Chat(val id: Long) : Screen
|
data class Chat(val id: Long) : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Profile(val pubkey: String) : Screen
|
data class Profile(val pubkey: String) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object ContactList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object UpdateProfile : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object NewChat : Screen
|
data object NewChat : Screen
|
||||||
|
|
||||||
|
|||||||
@@ -10,43 +10,48 @@ import android.content.pm.ServiceInfo
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun isUserInApp(): Boolean {
|
|
||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
val notification = createNotification()
|
startAsForeground()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
||||||
} else {
|
|
||||||
startForeground(1, notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
serviceScope.launch {
|
if (intent?.action == "STOP_SERVICE") {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the nostr service in the foreground
|
||||||
|
startAsForeground()
|
||||||
|
|
||||||
|
// Check if the service is already running
|
||||||
|
if (notificationJob?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
// Start the Nostr client
|
||||||
|
notificationJob = serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
Log.d("Coop", "Starting Nostr in background")
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
|
|
||||||
@@ -55,7 +60,11 @@ class NostrForegroundService : Service() {
|
|||||||
dbDir.mkdirs()
|
dbDir.mkdirs()
|
||||||
|
|
||||||
// Initialize Nostr client
|
// Initialize Nostr client
|
||||||
|
try {
|
||||||
nostr.init(dbDir.absolutePath)
|
nostr.init(dbDir.absolutePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||||
|
}
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
nostr.connectBootstrapRelays()
|
nostr.connectBootstrapRelays()
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
@@ -82,10 +91,30 @@ class NostrForegroundService : Service() {
|
|||||||
Log.e("Coop", "Failed to start Nostr", e)
|
Log.e("Coop", "Failed to start Nostr", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
private fun isUserInApp(): Boolean {
|
||||||
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAsForeground() {
|
||||||
|
val notification = createNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStopServicePendingIntent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NostrForegroundService::class.java).apply {
|
||||||
|
action = "STOP_SERVICE"
|
||||||
|
}
|
||||||
|
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
@@ -108,10 +137,12 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
private fun createNotification(content: String? = null): Notification {
|
private fun createNotification(content: String? = null): Notification {
|
||||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||||
|
.setGroup(GROUP_KEY_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setCategory(Notification.CATEGORY_SERVICE)
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
builder.setContentTitle("Coop")
|
builder.setContentTitle("Coop")
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
@@ -50,10 +51,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
@@ -65,7 +66,6 @@ import su.reya.coop.roomId
|
|||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.displayNameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(id: Long) {
|
fun ChatScreen(id: Long) {
|
||||||
@@ -73,28 +73,35 @@ fun ChatScreen(id: Long) {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
// Get chat room by ID
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState()
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } }
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
|
// Show empty screen
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
LoadingIndicator()
|
Text(
|
||||||
|
text = "Something went wrong.",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
|
|
||||||
val groupedMessages = remember(messages.toList()) {
|
val groupedMessages = remember(messages.toList()) {
|
||||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||||
}
|
}
|
||||||
@@ -108,21 +115,12 @@ fun ChatScreen(id: Long) {
|
|||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages)
|
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
|
// Stop loading spinner
|
||||||
loading = false
|
loading = false
|
||||||
|
|
||||||
|
// Get msg relays for each member
|
||||||
|
viewModel.chatRoomConnect(id)
|
||||||
|
|
||||||
// Handle new messages
|
// Handle new messages
|
||||||
viewModel.newEvents.collect { event ->
|
viewModel.newEvents.collect { event ->
|
||||||
if (event.roomId() == id) {
|
if (event.roomId() == id) {
|
||||||
@@ -151,7 +149,7 @@ fun ChatScreen(id: Long) {
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
room.members.firstOrNull()?.let { pubkey ->
|
room!!.members.firstOrNull()?.let { pubkey ->
|
||||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
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_check
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
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.Avatar
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListScreen() {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val contactList by viewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "My Contacts",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
|
contentDescription = "Scanner"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Contact") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddContactDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Contact"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Contact") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.padding(innerPadding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
|
) {
|
||||||
|
if (contactList.isNotEmpty()) {
|
||||||
|
contactList.forEachIndexed { index, pubkey ->
|
||||||
|
ContactListItem(
|
||||||
|
pubkey = pubkey,
|
||||||
|
index = index,
|
||||||
|
total = contactList.size,
|
||||||
|
onClick = {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No contacts yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your contacts will appear here",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (openAddContactDialog) {
|
||||||
|
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
var contact by remember { mutableStateOf("") }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Contact",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onDismissRequest() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val success = viewModel.addContact(contact)
|
||||||
|
if (success) onDismissRequest()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contact,
|
||||||
|
onValueChange = { contact = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = contact.isNotEmpty() && !verifyContact(contact)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Contact Address") },
|
||||||
|
placeholder = { Text(text = "npub1... or user@example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Contact address is invalid")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add contact you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListItem(
|
||||||
|
pubkey: PublicKey,
|
||||||
|
index: Int,
|
||||||
|
total: Int = 0,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||||
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||||
|
val picture = profile?.picture
|
||||||
|
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = { viewModel.removeContact(pubkey) },
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
|
index = index,
|
||||||
|
count = total
|
||||||
|
),
|
||||||
|
leadingContent = {
|
||||||
|
Avatar(
|
||||||
|
picture = picture,
|
||||||
|
description = displayName,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyContact(address: String): Boolean {
|
||||||
|
return try {
|
||||||
|
if (address.contains("@")) Nip05Address.parse(address)
|
||||||
|
else PublicKey.parse(address)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse contact: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -72,14 +73,21 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -88,10 +96,12 @@ import su.reya.coop.LocalNostrViewModel
|
|||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.ago
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.displayNameFlow
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@@ -105,22 +115,25 @@ fun HomeScreen() {
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
|
||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
|
||||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
val currentUser = viewModel.currentUser() ?: return
|
||||||
|
val currentUserProfile = viewModel.getMetadata(currentUser)
|
||||||
|
|
||||||
|
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 expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
var isBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
var isNotificationEnabled by remember {
|
var isNotificationEnabled by remember {
|
||||||
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
||||||
@@ -132,6 +145,11 @@ fun HomeScreen() {
|
|||||||
// State will be updated by LifecycleResumeEffect
|
// State will be updated by LifecycleResumeEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partition chat rooms into requests and ongoing
|
||||||
|
val (requests, ongoing) = remember(chatRooms) {
|
||||||
|
chatRooms.partition { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
LifecycleResumeEffect(context) {
|
LifecycleResumeEffect(context) {
|
||||||
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
@@ -342,7 +360,11 @@ fun HomeScreen() {
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
if (requests.isNotEmpty()) {
|
||||||
|
item { NewRequests(requests) }
|
||||||
|
}
|
||||||
|
|
||||||
|
items(ongoing, key = { it.id }) { room ->
|
||||||
ChatRoom(
|
ChatRoom(
|
||||||
room = room,
|
room = room,
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
@@ -351,14 +373,15 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
modifier = Modifier
|
|
||||||
.imePadding()
|
|
||||||
.navigationBarsPadding(),
|
|
||||||
) {
|
) {
|
||||||
val pubkey = viewModel.currentUser()
|
val pubkey = viewModel.currentUser()
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||||
@@ -443,9 +466,237 @@ 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(horizontal = 24.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Messaging Relays are missing",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
shape = MaterialShapes.Circle.toShape(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "X",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Other people won't be able to send you messages.",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
shape = MaterialShapes.Circle.toShape(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "X",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "You cannot store your messages.",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "If you believe this is a mistake, please click the Retry button to check again.",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (isBusy) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
enabled = !isBusy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
isBusy = true
|
||||||
|
try {
|
||||||
|
viewModel.refetchMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
|
}
|
||||||
|
isBusy = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Retry",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
enabled = !isBusy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
viewModel.useDefaultMsgRelayList()
|
||||||
|
sheetState.hide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Use Default",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewRequests(requests: List<Room>) {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val total = requests.size
|
||||||
|
val firstRoom = requests.getOrNull(0)
|
||||||
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
|
val firstName by remember(firstRoom) {
|
||||||
|
firstRoom?.displayNameFlow(viewModel) ?: flowOf("")
|
||||||
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
|
val secondName by remember(secondRoom) {
|
||||||
|
secondRoom?.displayNameFlow(viewModel) ?: flowOf("")
|
||||||
|
}.collectAsStateWithLifecycle("")
|
||||||
|
|
||||||
|
val supportingText = when {
|
||||||
|
total == 1 && firstRoom != null -> {
|
||||||
|
val message = firstRoom.lastMessage ?: ""
|
||||||
|
"$firstName: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
total == 2 -> {
|
||||||
|
"$firstName and $secondName"
|
||||||
|
}
|
||||||
|
|
||||||
|
total > 2 -> {
|
||||||
|
val othersCount = total - 2
|
||||||
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
|
"$firstName, $secondName and $othersText"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
navigator.navigate(Screen.RequestList)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_request),
|
||||||
|
contentDescription = "Requests",
|
||||||
|
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "Requests",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (supportingText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +733,8 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
if (!room.lastMessage.isNullOrBlank()) {
|
if (!room.lastMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = room.lastMessage!!,
|
text = room.lastMessage!!,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -501,9 +753,9 @@ fun BottomMenuList(
|
|||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Spams & Blocks" to { },
|
|
||||||
"Contacts" to { },
|
|
||||||
"Settings" to { }
|
"Settings" to { }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ fun ImportScreen() {
|
|||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
|
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ fun ImportScreen() {
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
enabled = !isLoggedIn,
|
enabled = !isBusy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -258,9 +258,9 @@ fun ImportScreen() {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !isLoggedIn,
|
enabled = secret.isNotBlank() && !isBusy,
|
||||||
) {
|
) {
|
||||||
if (isLoggedIn) {
|
if (isBusy) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -78,7 +79,7 @@ fun NewChatScreen() {
|
|||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
delay(500) // 500ms debounce
|
delay(500.milliseconds)
|
||||||
|
|
||||||
if (query.startsWith("npub1")) {
|
if (query.startsWith("npub1")) {
|
||||||
val pubkey = try {
|
val pubkey = try {
|
||||||
|
|||||||
@@ -1,307 +1,31 @@
|
|||||||
package su.reya.coop.screens
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
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.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen() {
|
||||||
val context = LocalContext.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val focusManager = LocalFocusManager.current
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
var bio by remember { mutableStateOf("") }
|
|
||||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
ProfileEditor(
|
||||||
contract = ActivityResultContracts.GetContent()
|
title = "Create a new identity",
|
||||||
) { uri: Uri? ->
|
buttonLabel = "Continue",
|
||||||
picture = uri
|
isBusy = isBusy,
|
||||||
}
|
onBack = { navigator.goBack() },
|
||||||
|
onConfirm = { name, bio, bytes, type ->
|
||||||
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 },
|
|
||||||
enabled = !isLoggedIn,
|
|
||||||
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 = !isLoggedIn,
|
|
||||||
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 = {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
viewModel.createIdentity(name, bio, bytes, type)
|
||||||
val imageBytes = withContext(Dispatchers.IO) {
|
|
||||||
picture?.let { uri ->
|
|
||||||
context.contentResolver.openInputStream(
|
|
||||||
uri
|
|
||||||
)?.use { input -> input.readBytes() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val contentType =
|
|
||||||
picture?.let { context.contentResolver.getType(it) }
|
|
||||||
|
|
||||||
// Create the identity
|
|
||||||
viewModel.createIdentity(name, bio, imageBytes, contentType)
|
|
||||||
|
|
||||||
// Navigate to the home screen if successful
|
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
} catch (e: Exception) {
|
|
||||||
// Error is handled by viewModel.showError inside createIdentity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
|
||||||
enabled = name.isNotBlank() && !isLoggedIn,
|
|
||||||
) {
|
|
||||||
if (isLoggedIn) {
|
|
||||||
LoadingIndicator()
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Continue",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
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.rotate
|
||||||
import androidx.compose.ui.graphics.drawscope.translate
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
@@ -45,11 +54,15 @@ import su.reya.coop.shared.getExpressiveFontFamily
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen() {
|
fun OnboardingScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
val expressiveFont = getExpressiveFontFamily()
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
|
||||||
val annotatedText = buildAnnotatedString {
|
val annotatedText = buildAnnotatedString {
|
||||||
append("By using Coop, you agree to accept\nour ")
|
append("By using Coop, you agree to accept\nour ")
|
||||||
// Push "Terms of Use" link
|
// Push "Terms of Use" link
|
||||||
@@ -142,7 +155,44 @@ fun OnboardingScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
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) },
|
onClick = { navigator.navigate(Screen.Import) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Message",
|
text = "Message",
|
||||||
style = MaterialTheme.typography.labelSmall
|
style = MaterialTheme.typography.labelMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -3,34 +3,65 @@ package su.reya.coop.screens
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedListItem
|
import androidx.compose.material3.SegmentedListItem
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
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
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_check
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
@@ -45,6 +76,7 @@ fun RelayScreen() {
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
||||||
|
|
||||||
@@ -60,6 +92,9 @@ fun RelayScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||||
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(viewModel.currentUserRelayList())
|
relayList.putAll(viewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
||||||
@@ -86,9 +121,33 @@ fun RelayScreen() {
|
|||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Relay") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddRelayDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Relay"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Relay") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -113,7 +172,8 @@ fun RelayScreen() {
|
|||||||
if (msgRelayList.isNotEmpty()) {
|
if (msgRelayList.isNotEmpty()) {
|
||||||
msgRelayList.forEachIndexed { index, relayUrl ->
|
msgRelayList.forEachIndexed { index, relayUrl ->
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = { },
|
onClick = { /* No action */ },
|
||||||
|
onLongClick = { relayToDelete = relayUrl.toString() },
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = msgRelayList.size
|
count = msgRelayList.size
|
||||||
@@ -233,4 +293,223 @@ fun RelayScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (openAddRelayDialog) {
|
||||||
|
AddRelayDialog(
|
||||||
|
onDismissRequest = { openAddRelayDialog = false },
|
||||||
|
onMsgRelayAdded = { newRelay ->
|
||||||
|
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||||
|
},
|
||||||
|
onRelayAdded = { newRelay, metadata ->
|
||||||
|
relayList[RelayUrl.parse(newRelay)] = metadata
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayToDelete != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { relayToDelete = null },
|
||||||
|
title = { Text("Remove Relay") },
|
||||||
|
text = { Text("Are you sure you want to remove $relayToDelete?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (msgRelayList.size == 1) {
|
||||||
|
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||||
|
relayToDelete = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
viewModel.removeMsgRelay(relayToDelete!!)
|
||||||
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
|
relayToDelete = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Failed to remove relay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Confirm")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { relayToDelete = null }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddRelayDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
|
) {
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var relayAddress by remember { mutableStateOf("") }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
val roles = listOf("Messaging", "Inbox", "Outbox")
|
||||||
|
val (selected, onSelected) = remember { mutableStateOf(roles[0]) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Relay",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onDismissRequest() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (!isError) {
|
||||||
|
when (selected) {
|
||||||
|
"Messaging" -> {
|
||||||
|
viewModel.addMsgRelay(relayAddress)
|
||||||
|
onMsgRelayAdded(relayAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
"Inbox" -> {
|
||||||
|
viewModel.addInboxRelay(relayAddress)
|
||||||
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"Outbox" -> {
|
||||||
|
viewModel.addOutboxRelay(relayAddress)
|
||||||
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = relayAddress,
|
||||||
|
onValueChange = { relayAddress = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Relay Address") },
|
||||||
|
placeholder = { Text(text = "wss://relay.example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Invalid format. Must start with wss://")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add relays you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Relay Roles",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup(),
|
||||||
|
) {
|
||||||
|
roles.forEach { text ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.selectable(
|
||||||
|
onClick = { onSelected(text) },
|
||||||
|
selected = (text == selected),
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (text == selected),
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyRelayUrl(url: String): Boolean {
|
||||||
|
return try {
|
||||||
|
RelayUrl.parse(url)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse relay url: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RequestListScreen() {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Get all request rooms
|
||||||
|
val requests = remember(chatRooms) {
|
||||||
|
chatRooms.filter { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text("New Requests", style = MaterialTheme.typography.titleMediumEmphasized)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
PullToRefreshBox(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
state = pullToRefreshState,
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch {
|
||||||
|
isRefreshing = true
|
||||||
|
viewModel.refreshChatRooms()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indicator = {
|
||||||
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = pullToRefreshState,
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (requests.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No requests yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "New chat requests will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(requests.toList(), key = { it.id }) { room ->
|
||||||
|
ChatRoom(
|
||||||
|
room = room,
|
||||||
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,9 +30,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
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-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.16.2")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,27 +38,39 @@ import rust.nostr.sdk.PublicKey
|
|||||||
import rust.nostr.sdk.RelayCapabilities
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
import rust.nostr.sdk.RelayMessageEnum
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayStatus
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.ReqExitPolicy
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
import rust.nostr.sdk.ReqTarget
|
import rust.nostr.sdk.ReqTarget
|
||||||
import rust.nostr.sdk.SendEventTarget
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SignerAuthenticator
|
||||||
import rust.nostr.sdk.SingleLetterTag
|
import rust.nostr.sdk.SingleLetterTag
|
||||||
import rust.nostr.sdk.SleepWhenIdle
|
import rust.nostr.sdk.SleepWhenIdle
|
||||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.TagKind
|
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
import rust.nostr.sdk.UnwrappedGift
|
||||||
import rust.nostr.sdk.extractRelayList
|
import rust.nostr.sdk.extractRelayList
|
||||||
import rust.nostr.sdk.giftWrapAsync
|
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
object NostrManager {
|
object NostrManager {
|
||||||
val instance = Nostr()
|
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 {
|
class Nostr {
|
||||||
@@ -75,7 +87,6 @@ class Nostr {
|
|||||||
|
|
||||||
private val isInitialized = MutableStateFlow(false)
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
// Add these to the Nostr class
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
@@ -99,34 +110,33 @@ class Nostr {
|
|||||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||||
_contactListUpdates.emit(contacts)
|
_contactListUpdates.emit(contacts)
|
||||||
|
|
||||||
suspend fun init(dbPath: String) {
|
suspend fun init(
|
||||||
|
dbPath: String,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
if (isInitialized.value) return
|
if (isInitialized.value) return
|
||||||
|
|
||||||
// Initialize the logger for nostr client
|
// Initialize the logger for nostr client
|
||||||
initLogger(LogLevel.DEBUG)
|
initLogger(logLevel)
|
||||||
|
|
||||||
// Initialize the database and gossip instance
|
// Initialize configurations for nostr client
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
val gossip = NostrGossip.inMemory()
|
val gossip = NostrGossip.inMemory()
|
||||||
|
val authenticator = SignerAuthenticator(signer)
|
||||||
// Set the idle timeout for relays
|
|
||||||
val idleTimeout = Duration.parse("5m")
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
client =
|
client =
|
||||||
ClientBuilder()
|
ClientBuilder()
|
||||||
.signer(signer)
|
.authenticator(authenticator)
|
||||||
.database(lmdb)
|
.database(lmdb)
|
||||||
.gossip(gossip)
|
.gossip(gossip)
|
||||||
.gossipConfig(
|
.gossipConfig(
|
||||||
GossipConfig()
|
GossipConfig()
|
||||||
.noBackgroundRefresh()
|
.noBackgroundRefresh()
|
||||||
.fetchTimeout(Duration.parse("2s"))
|
.fetchTimeout(Duration.parse("2s"))
|
||||||
.syncIdleTimeout(Duration.parse("100ms"))
|
|
||||||
.syncInitialTimeout(Duration.parse("100ms"))
|
|
||||||
)
|
)
|
||||||
.verifySubscriptions(false)
|
.verifySubscriptions(false)
|
||||||
.automaticAuthentication(true)
|
|
||||||
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -141,29 +151,43 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
suspend fun connectBootstrapRelays() {
|
||||||
// Bootstrap relays
|
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
|
}
|
||||||
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
|
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||||
|
|
||||||
|
|
||||||
// Indexer relay for NIP-65 discovery
|
|
||||||
client?.addRelay(
|
client?.addRelay(
|
||||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
url = RelayUrl.parse(url),
|
||||||
capabilities = RelayCapabilities.gossip()
|
capabilities = RelayCapabilities.gossip()
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
// Connect to all bootstrap relays
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to all bootstrap relays and wait for all connections to be established
|
suspend fun reconnect() {
|
||||||
client?.connect(Duration.parse("2s"))
|
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() {
|
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() {
|
|
||||||
signer.switch(Keys.generate())
|
|
||||||
deviceSigner = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
@@ -355,28 +379,25 @@ class Nostr {
|
|||||||
|
|
||||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
try {
|
try {
|
||||||
val currentUser =
|
// Construct the room id
|
||||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
// Ensure the rumor ID is set
|
|
||||||
val rumor = rumor.ensureId()
|
|
||||||
val roomId = rumor.roomId()
|
val roomId = rumor.roomId()
|
||||||
|
|
||||||
// Construct reference tags
|
// Construct reference tags
|
||||||
val tags = listOf(
|
val tags = listOf(
|
||||||
Tag.identifier(giftId.toHex()),
|
Tag.identifier(giftId.toHex()),
|
||||||
|
Tag.publicKey(rumor.author()),
|
||||||
Tag.event(rumor.id()!!),
|
Tag.event(rumor.id()!!),
|
||||||
Tag.reference(roomId.toString()),
|
Tag.custom("r", listOf(roomId.toString())),
|
||||||
Tag.custom(TagKind.Unknown("k"), listOf("14"))
|
Tag.custom("k", listOf("14"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set event kind
|
// Set event kind
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
||||||
|
|
||||||
|
// Construct event
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.build(currentUser)
|
.finalizeAsync(Keys.generate())
|
||||||
.signWithKeys(Keys.generate())
|
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
client?.database()?.saveEvent(event)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -385,27 +406,22 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
|
try {
|
||||||
// Check if the rumor is already cached
|
// Check if the rumor is already cached
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
if (cachedRumor != null) return cachedRumor
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
// Get all signers
|
// Unwrap the gift with current signer
|
||||||
val signers = listOfNotNull(signer, deviceSigner)
|
|
||||||
if (signers.isEmpty()) return null
|
|
||||||
|
|
||||||
// Try to unwrap the gift with each signer
|
|
||||||
for (signer in signers) {
|
|
||||||
try {
|
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
||||||
val rumor = gift.rumor()
|
val rumor = gift.rumor()
|
||||||
|
|
||||||
// Save the rumor to the database
|
// Save the rumor to the database
|
||||||
setCachedRumor(event.id(), rumor)
|
setCachedRumor(event.id(), rumor)
|
||||||
|
|
||||||
// Return the rumor
|
// Return the rumor
|
||||||
return rumor
|
return rumor
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to unwrap gift: ${e.message}")
|
println("Failed to unwrap gift: ${e.message}")
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -425,9 +441,10 @@ class Nostr {
|
|||||||
client?.addRelay(
|
client?.addRelay(
|
||||||
url = relay,
|
url = relay,
|
||||||
capabilities =
|
capabilities =
|
||||||
if (metadata == RelayMetadata.READ) RelayCapabilities.read()
|
when (metadata) {
|
||||||
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
|
RelayMetadata.READ -> RelayCapabilities.read()
|
||||||
else RelayCapabilities.none()
|
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
client?.connectRelay(relay)
|
client?.connectRelay(relay)
|
||||||
}
|
}
|
||||||
@@ -438,7 +455,7 @@ class Nostr {
|
|||||||
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||||
// Construct a list of messaging relays
|
// Construct a list of messaging relays
|
||||||
val msgRelayList = listOf(
|
val msgRelayList = listOf(
|
||||||
RelayUrl.parse("wss://relay.0xchat.com"),
|
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||||
RelayUrl.parse("wss://nip17.com"),
|
RelayUrl.parse("wss://nip17.com"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -454,7 +471,7 @@ class Nostr {
|
|||||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
// Send relay list event
|
// Send relay list event
|
||||||
val relayList = getDefaultRelayList()
|
val relayList = getDefaultRelayList()
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = relayListEvent,
|
event = relayListEvent,
|
||||||
@@ -465,7 +482,7 @@ class Nostr {
|
|||||||
|
|
||||||
// Send messaging relay list event
|
// Send messaging relay list event
|
||||||
val msgRelayList = getDefaultMsgRelayList()
|
val msgRelayList = getDefaultMsgRelayList()
|
||||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = msgRelayListEvent,
|
event = msgRelayListEvent,
|
||||||
@@ -476,7 +493,7 @@ class Nostr {
|
|||||||
// Send metadata event
|
// Send metadata event
|
||||||
val metadata =
|
val metadata =
|
||||||
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
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(
|
client?.sendEvent(
|
||||||
event = metadataEvent,
|
event = metadataEvent,
|
||||||
@@ -486,8 +503,8 @@ class Nostr {
|
|||||||
|
|
||||||
// Send contact list event
|
// Send contact list event
|
||||||
val defaultContact =
|
val defaultContact =
|
||||||
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
|
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||||
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
|
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = contactListEvent,
|
event = contactListEvent,
|
||||||
@@ -498,6 +515,48 @@ class Nostr {
|
|||||||
setSigner(keys)
|
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> {
|
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||||
try {
|
try {
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||||
@@ -536,7 +595,6 @@ class Nostr {
|
|||||||
ReqTarget.manual(
|
ReqTarget.manual(
|
||||||
mapOf(
|
mapOf(
|
||||||
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
|
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),
|
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -549,7 +607,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
try {
|
try {
|
||||||
val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = event,
|
event = event,
|
||||||
@@ -580,6 +638,19 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
try {
|
try {
|
||||||
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
@@ -592,14 +663,45 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val contacts = contacts.map { Contact(it) }
|
||||||
|
val event = EventBuilder.contactList(contacts).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getChatRooms(): Set<Room>? {
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
try {
|
try {
|
||||||
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
val userPubkey =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
// Get all events sent by the user
|
// Get all events sent by the user
|
||||||
val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm"))
|
val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
@@ -615,8 +717,9 @@ class Nostr {
|
|||||||
|
|
||||||
// Check if the room already exists
|
// Check if the room already exists
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
val filter =
|
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
||||||
Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList())
|
val pubkeys = newRoom.members.toList()
|
||||||
|
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
|
||||||
|
|
||||||
// Determine if it's an ongoing room
|
// Determine if it's an ongoing room
|
||||||
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
|
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
|
||||||
@@ -652,33 +755,25 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun chatRoomConnect(members: List<PublicKey>): Map<PublicKey, List<RelayUrl>> {
|
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||||
try {
|
try {
|
||||||
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
|
|
||||||
|
|
||||||
members.forEach { member ->
|
members.forEach { member ->
|
||||||
results[member] = mutableListOf<RelayUrl>()
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
val stream = client?.streamEvents(
|
||||||
target = ReqTarget.auto(listOf(filter)),
|
target = ReqTarget.auto(listOf(filter)),
|
||||||
id = "room-${member.toBech32().substring(0, 10)}",
|
id = null,
|
||||||
timeout = Duration.parse("3s"),
|
timeout = Duration.parse("3s"),
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
)
|
)
|
||||||
|
|
||||||
stream?.next()?.let { res ->
|
stream?.next()?.let { res ->
|
||||||
if (res.event != null) {
|
if (res.event != null) {
|
||||||
// Connect to the msg relays
|
|
||||||
connectMsgRelays(res.event!!)
|
connectMsgRelays(res.event!!)
|
||||||
// Mark the member as connected
|
|
||||||
results[member]?.add(res.relayUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -688,11 +783,9 @@ class Nostr {
|
|||||||
try {
|
try {
|
||||||
val urls = nip17ExtractRelayList(event);
|
val urls = nip17ExtractRelayList(event);
|
||||||
for (url in urls) {
|
for (url in urls) {
|
||||||
if (client?.relay(url) == null) {
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
client?.addRelay(url)
|
|
||||||
client?.connectRelay(url)
|
client?.connectRelay(url)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -707,13 +800,13 @@ class Nostr {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
val currentUser =
|
val currentUser =
|
||||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
val tags = mutableListOf<Tag>()
|
val tags = mutableListOf<Tag>()
|
||||||
|
|
||||||
// Add a subject tag if provided
|
// Add a subject tag if provided
|
||||||
if (subject != null) {
|
if (subject != null) {
|
||||||
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
|
tags.add(Tag.custom("subject", listOf(subject)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event tags for replies
|
// Add event tags for replies
|
||||||
@@ -731,12 +824,9 @@ class Nostr {
|
|||||||
for (receiver in setOf(currentUser) + to) {
|
for (receiver in setOf(currentUser) + to) {
|
||||||
// Construct the rumor event
|
// Construct the rumor event
|
||||||
// NEVER SIGN this event with the current user signer
|
// NEVER SIGN this event with the current user signer
|
||||||
val rumor = EventBuilder
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||||
.privateMsgRumor(receiver = receiver, message = content)
|
|
||||||
.tags(tags)
|
.tags(tags)
|
||||||
.allowSelfTagging()
|
.finalizeUnsigned(currentUser)
|
||||||
.build(currentUser)
|
|
||||||
// Ensure the event ID is set
|
|
||||||
.ensureId()
|
.ensureId()
|
||||||
|
|
||||||
// Emit the rumor to the chat screen
|
// Emit the rumor to the chat screen
|
||||||
@@ -745,12 +835,12 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct the gift wrap event
|
// Construct the gift wrap event
|
||||||
val gift = giftWrapAsync(
|
val gift = nip59MakeGiftWrapAsync(
|
||||||
signer = signer,
|
signer = signer,
|
||||||
receiverPubkey = receiver,
|
receiverPubkey = receiver,
|
||||||
rumor = rumor,
|
rumor = rumor,
|
||||||
extraTags = listOf(
|
extraTags = listOf(
|
||||||
Tag.custom(TagKind.Unknown("k"), listOf("14"))
|
Tag.custom("k", listOf("14"))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -811,13 +901,12 @@ class Nostr {
|
|||||||
|
|
||||||
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
||||||
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
||||||
val target =
|
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||||
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
|
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
val stream = client?.streamEvents(
|
||||||
target = target,
|
target = target,
|
||||||
id = "search",
|
id = "search",
|
||||||
timeout = Duration.parse("4s"),
|
timeout = Duration.parse("3s"),
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -23,6 +26,8 @@ import rust.nostr.sdk.AsyncNostrSigner
|
|||||||
import rust.nostr.sdk.EventBuilder
|
import rust.nostr.sdk.EventBuilder
|
||||||
import rust.nostr.sdk.EventId
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
import rust.nostr.sdk.Metadata
|
import rust.nostr.sdk.Metadata
|
||||||
import rust.nostr.sdk.NostrConnect
|
import rust.nostr.sdk.NostrConnect
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
@@ -39,7 +44,8 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
|
|
||||||
class NostrViewModel(
|
class NostrViewModel(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val secretStore: SecretStorage
|
private val secretStore: SecretStorage,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||||
@@ -47,14 +53,8 @@ class NostrViewModel(
|
|||||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||||
val signerRequired = _signerRequired.asStateFlow()
|
val signerRequired = _signerRequired.asStateFlow()
|
||||||
|
|
||||||
private val _isLoggedIn = MutableStateFlow(false)
|
private val _isBusy = MutableStateFlow(false)
|
||||||
val isLoggedIn = _isLoggedIn.asStateFlow()
|
val isBusy = _isBusy.asStateFlow()
|
||||||
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||||
@@ -62,6 +62,12 @@ class NostrViewModel(
|
|||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
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)
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
@@ -76,28 +82,43 @@ class NostrViewModel(
|
|||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
init {
|
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
|
// Check if the notification banner has been dismissed
|
||||||
checkNotificationBannerDismissedStatus()
|
checkNotificationBannerDismissedStatus()
|
||||||
|
|
||||||
// Check local stored secret (secret key or bunker)
|
// Check local stored secret (secret key or bunker)
|
||||||
login()
|
login()
|
||||||
|
|
||||||
|
// Automatically reconnect bootstrap relays
|
||||||
|
reconnect()
|
||||||
|
|
||||||
// Observe the signer state and verify the relay list
|
// Observe the signer state and verify the relay list
|
||||||
observeSignerAndCheckRelays()
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
// Get all local stored metadata
|
// Get all local stored metadata
|
||||||
getCacheMetadata()
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
// Observe new events from the Nostr client
|
fun bindLifecycle(lifecycle: Lifecycle) {
|
||||||
runObserver()
|
viewModelScope.launch {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
// Wait and merge metadata requests into a single batch
|
coroutineScope {
|
||||||
runMetadataBatching()
|
launch { refreshChatRooms() }
|
||||||
|
launch { runObserver() }
|
||||||
|
launch { runMetadataBatching() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
// Ensure all relays are disconnect
|
|
||||||
|
// Disconnect to all bootstrap relays
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
nostr.disconnect()
|
nostr.disconnect()
|
||||||
@@ -118,8 +139,14 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun runObserver() {
|
private fun reconnect() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runObserver() = coroutineScope {
|
||||||
// Observe new messages
|
// Observe new messages
|
||||||
launch {
|
launch {
|
||||||
nostr.newEvents.collect { event ->
|
nostr.newEvents.collect { event ->
|
||||||
@@ -140,13 +167,6 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe metadata updates
|
|
||||||
launch {
|
|
||||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe contact list updates
|
// Observe contact list updates
|
||||||
launch {
|
launch {
|
||||||
nostr.contactListUpdates.collect { contacts ->
|
nostr.contactListUpdates.collect { contacts ->
|
||||||
@@ -154,6 +174,13 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observe metadata updates
|
||||||
|
launch {
|
||||||
|
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observes subscription close
|
// Observes subscription close
|
||||||
launch {
|
launch {
|
||||||
nostr.subscriptionClosed.collect {
|
nostr.subscriptionClosed.collect {
|
||||||
@@ -162,10 +189,8 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun runMetadataBatching() {
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
// Wait until the client is ready
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
@@ -182,11 +207,13 @@ class NostrViewModel(
|
|||||||
metadataRequestChannel.receive()
|
metadataRequestChannel.receive()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextKey != null) {
|
// Only add the key if it's not null
|
||||||
batch.add(nextKey)
|
if (nextKey != null) batch.add(nextKey)
|
||||||
}
|
|
||||||
|
|
||||||
|
// Get current time
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
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) {
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
val keysToRequest = batch.toList()
|
val keysToRequest = batch.toList()
|
||||||
batch.clear()
|
batch.clear()
|
||||||
@@ -196,7 +223,6 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheMetadata() {
|
private fun getCacheMetadata() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -216,7 +242,9 @@ class NostrViewModel(
|
|||||||
private fun login() {
|
private fun login() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val secret = secretStore.get("user_signer")
|
val secret = withTimeoutOrNull(3.seconds) {
|
||||||
|
secretStore.get("user_signer")
|
||||||
|
}
|
||||||
|
|
||||||
if (secret == null) {
|
if (secret == null) {
|
||||||
_signerRequired.value = true
|
_signerRequired.value = true
|
||||||
@@ -256,13 +284,11 @@ class NostrViewModel(
|
|||||||
nostr.getUserMetadata()
|
nostr.getUserMetadata()
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
// Small delay to ensure all relays are connected
|
||||||
delay(3000.milliseconds)
|
delay(2.seconds)
|
||||||
|
|
||||||
// Check if the relay list is empty
|
// Check if the relay list is empty
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
val relays = nostr.getMsgRelays(pubkey)
|
||||||
if (relays.isEmpty()) {
|
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||||
_isRelayListEmpty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -330,34 +356,9 @@ class NostrViewModel(
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
_isLoggedIn.value = true
|
|
||||||
try {
|
try {
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
var avatarUrl = ""
|
|
||||||
|
|
||||||
// Upload picture to Blossom
|
// Upload picture to Blossom
|
||||||
if (picture != null) {
|
|
||||||
val blossom = BlossomClient(
|
val blossom = BlossomClient(
|
||||||
url = "https://blossom.band",
|
url = "https://blossom.band",
|
||||||
client = HttpClient {
|
client = HttpClient {
|
||||||
@@ -372,26 +373,87 @@ class NostrViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
val descriptor = blossom.upload(
|
||||||
file = picture,
|
file = file,
|
||||||
contentType = contentType,
|
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
|
// Create identity
|
||||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
// Save secret to the secret storage
|
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
|
// Update local states
|
||||||
// Set an empty secret state
|
_isBusy.value = false
|
||||||
_signerRequired.value = false
|
_signerRequired.value = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
} finally {
|
}
|
||||||
_isLoggedIn.value = true
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,19 +471,64 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String) {
|
suspend fun importIdentity(secret: String) {
|
||||||
_isLoggedIn.value = true
|
_isBusy.value = true
|
||||||
try {
|
try {
|
||||||
val signer = createSigner(secret)
|
val signer = createSigner(secret)
|
||||||
|
// Update signer
|
||||||
nostr.setSigner(signer)
|
nostr.setSigner(signer)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
|
// Update local states
|
||||||
|
_signerRequired.value = false
|
||||||
|
_isBusy.value = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
} finally {
|
|
||||||
_signerRequired.value = false
|
|
||||||
_isLoggedIn.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 refetchMsgRelays(pubkey: PublicKey) {
|
||||||
|
val relays = nostr.fetchMsgRelays(pubkey)
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
suspend fun useDefaultMsgRelayList() {
|
||||||
try {
|
try {
|
||||||
val defaultRelays = nostr.getDefaultMsgRelayList()
|
val defaultRelays = nostr.getDefaultMsgRelayList()
|
||||||
@@ -440,6 +547,42 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun addInboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
|
nostr.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addOutboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
|
nostr.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||||
try {
|
try {
|
||||||
return nostr.getMsgRelays(nostr.signer.currentUser!!)
|
return nostr.getMsgRelays(nostr.signer.currentUser!!)
|
||||||
@@ -449,6 +592,78 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun addMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.add(relayUrl)
|
||||||
|
|
||||||
|
nostr.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun newContact(publicKey: PublicKey) {
|
||||||
|
if (publicKey in contactList.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value + publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it + publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addContact(address: String): Boolean {
|
||||||
|
val pubkey = try {
|
||||||
|
if (address.contains("@")) {
|
||||||
|
nostr.searchByAddress(address)
|
||||||
|
} else {
|
||||||
|
PublicKey.parse(address)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Invalid contact address: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return run {
|
||||||
|
newContact(pubkey)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (publicKey !in contactList.value) return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value - publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
try {
|
try {
|
||||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||||
@@ -457,10 +672,9 @@ class NostrViewModel(
|
|||||||
val currentUser = nostr.signer.currentUser!!
|
val currentUser = nostr.signer.currentUser!!
|
||||||
|
|
||||||
// Construct the rumor event
|
// Construct the rumor event
|
||||||
val rumor = EventBuilder
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||||
.privateMsgRumor(to.first(), "")
|
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
.build(currentUser)
|
.finalizeUnsigned(currentUser)
|
||||||
|
|
||||||
// Check if the room already exists
|
// Check if the room already exists
|
||||||
val id = rumor.roomId()
|
val id = rumor.roomId()
|
||||||
@@ -485,9 +699,8 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room {
|
fun getChatRoom(id: Long): Room? {
|
||||||
return chatRooms.value.firstOrNull { it.id == id }
|
return chatRooms.value.firstOrNull { it.id == id }
|
||||||
?: throw IllegalArgumentException("Room not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
private fun mergeChatRooms(rooms: Set<Room>) {
|
||||||
@@ -528,15 +741,16 @@ class NostrViewModel(
|
|||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
fun chatRoomConnect(roomId: Long) {
|
||||||
val room = getChatRoom(roomId)
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
val members = room.members
|
val members = room.members
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
nostr.chatRoomConnect(members.toList())
|
nostr.chatRoomConnect(members.toList())
|
||||||
}.getOrElse { e ->
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
members.associateWith { emptyList<RelayUrl>() }
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +760,7 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId)
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
nostr.sendMessage(
|
nostr.sendMessage(
|
||||||
to = room.members,
|
to = room.members,
|
||||||
content = message,
|
content = message,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import kotlinx.datetime.minus
|
|||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.TagKind
|
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -37,7 +36,7 @@ data class Room(
|
|||||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||||
val id = rumor.roomId()
|
val id = rumor.roomId()
|
||||||
val createdAt = rumor.createdAt()
|
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
|
// Collect the author's public key and all public keys from tags
|
||||||
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
||||||
|
|||||||
@@ -2,31 +2,42 @@ package su.reya.coop
|
|||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.Event
|
import rust.nostr.sdk.Event
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var signer: AsyncNostrSigner = initialSigner
|
private var signer: AsyncNostrSigner = initialSigner
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var currentUser: PublicKey? = null
|
var currentUser: PublicKey? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current signer.
|
* Get the current signer.
|
||||||
*/
|
*/
|
||||||
suspend fun get(): AsyncNostrSigner = mutex.withLock {
|
fun get(): AsyncNostrSigner = signer
|
||||||
signer
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch to a new signer.
|
* Switch to a new signer.
|
||||||
*/
|
*/
|
||||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
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
|
signer = newSigner
|
||||||
currentUser = newSigner.getPublicKeyAsync()
|
currentUser = pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class BlossomClient(
|
|||||||
signer: AsyncNostrSigner,
|
signer: AsyncNostrSigner,
|
||||||
authz: BlossomAuthorization
|
authz: BlossomAuthorization
|
||||||
): HeaderValue {
|
): HeaderValue {
|
||||||
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
|
val authEvent = EventBuilder.blossomAuth(authz).finalizeAsync(signer)
|
||||||
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
||||||
val value = "Nostr $encodedAuth"
|
val value = "Nostr $encodedAuth"
|
||||||
return HeaderValue(value)
|
return HeaderValue(value)
|
||||||
|
|||||||
Reference in New Issue
Block a user