18 Commits

Author SHA1 Message Date
26f9e577f4 chore: bump version 2026-06-27 10:26:55 +07:00
dd416711e2 fix: app crash when message id is null (#28)
Reviewed-on: #28
2026-06-27 03:26:07 +00:00
b90279e174 chore: improve qr scan result handling (#27)
Reviewed-on: #27
2026-06-27 01:14:26 +00:00
25ba03562a chore: improve handling of loading states (#26)
Reviewed-on: #26
2026-06-26 09:23:03 +00:00
010a8b4060 feat: wipe database on logout (#25)
Reviewed-on: #25
2026-06-25 08:12:27 +00:00
750d46bc9e chore: update zapstore.yaml 2026-06-25 10:14:32 +07:00
790d24c24b chore: update metadata 2026-06-25 09:53:38 +07:00
4fbc8446d3 fix: release source endpoint 2026-06-25 09:26:15 +07:00
b4518ce360 chore: add app screenshots 2026-06-25 09:19:16 +07:00
0f4bb71a39 chore: prepare to publish 2026-06-24 17:36:04 +07:00
37003e2fc4 chore: bump version 2026-06-20 13:28:39 +07:00
154906017e feat: screener for message requests (#24)
Reviewed-on: #24
2026-06-19 09:06:49 +00:00
91e4e3b43d feat: support grouped new chat requests (#23)
Reviewed-on: #23
2026-06-18 00:34:23 +00:00
ea90a43909 feat: add contact list screen (#22)
Reviewed-on: #22
2026-06-15 07:38:53 +00:00
938b192136 fix: foreground service crashing (#21)
Reviewed-on: #21
2026-06-13 02:12:16 +00:00
1f966de654 chore: bump version 2026-06-12 15:50:14 +07:00
00821a864b fix: nostr operations cause app crashing (#20)
Reviewed-on: #20
2026-06-12 08:49:14 +00:00
28550f8e25 feat: Relay Management (#19)
Reviewed-on: #19
2026-06-11 10:40:36 +00:00
35 changed files with 2181 additions and 503 deletions

93
CHANGELOG.md Normal file
View File

@@ -0,0 +1,93 @@
### [2026-06-20] v0.2.0 (Stable)
**What's Changed:**
* Fixed the issue that cause app crashing
* Added screener for new message requests
* Added request messages screen
* Added contact list screen
---
### [2026-06-12] v0.1.9 (Pre-Release)
**What's Changed:**
* Fixed the issue that cause app crashing
* Added relay management
---
### [2026-06-09] v0.1.8 (Pre-Release)
**What's Changed:**
* Added support for NIP-55 (use external signer like Amber)
---
### [2026-06-07] v0.1.7 (Pre-Release)
**What's Changed:**
* Added a screen for update profile
* Fixed occasional splash screen hang
* Improved the battery usage in background
---
### [2026-06-04] v0.1.6 (Pre-Release)
**What's Changed:**
* Added self chat
* Fixed empty chat rooms on refreshes
* Fixed crash on startup
---
### [2026-06-03] v0.1.5 (Pre-Release)
**What's Changed:**
* Added crash report screen
* Added notification permission banner
* Fixed navigation issues after create or import identity
---
### [2026-06-01] v0.1.4 (Pre-Release)
**What's Changed:**
* Added the deep link to allow user open chat screen directly via notification
* Fixed the issue cause app crash if room has no member
* Improve stability and performance
---
### [2026-05-29] v0.1.3 (Pre-Release)
**What's Changed:**
* Added basic notification when the app in background
* Redesign the splash screen
* Redesign the onboarding screen
* Improved UX for the new identity creation and import screen
* Fixed some issues cause app crashes
---
### [2026-05-24] v0.1.2 (Pre-Release)
**What's Changed:**
* Fixed the issue that cause app crash when open new chat room via QR
---
### [2026-05-23] v0.1.1 (Pre-Release)
**What's Changed:**
* Fixed the issue that cause app crash on startup

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
assets/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

BIN
assets/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
assets/screenshot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
assets/screenshot5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -24,7 +24,7 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.6") implementation("su.reya:nostr-sdk-kmp:0.3.1")
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.8" versionName = "0.2.1"
} }
packaging { packaging {
resources { resources {

View File

@@ -8,7 +8,7 @@
<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> <queries>
@@ -59,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>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
</vector>

View File

@@ -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="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>

View File

@@ -61,7 +61,7 @@ class AndroidExternalSigner(
): String? { ): String? {
// Try Content Resolver first // Try Content Resolver first
queryContentResolver(type, payload, pubkey, currentUser)?.let { queryContentResolver(type, payload, pubkey, currentUser)?.let {
return it.result return if (resultKey == "event") it.event else it.result
} }
// Fall back to Intent // Fall back to Intent

View File

@@ -6,45 +6,25 @@ 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.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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
@@ -54,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
@@ -64,6 +44,7 @@ import su.reya.coop.screens.NewIdentityScreen
import su.reya.coop.screens.OnboardingScreen import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.ProfileScreen import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen import su.reya.coop.screens.UpdateProfileScreen
@@ -88,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.collectAsStateWithLifecycle() val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -187,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()
} }
@@ -197,7 +178,7 @@ fun App(viewModel: NostrViewModel) {
NewIdentityScreen() NewIdentityScreen()
} }
entry<Screen.Chat> { key -> entry<Screen.Chat> { key ->
ChatScreen(id = key.id) ChatScreen(id = key.id, screening = key.screening)
} }
entry<Screen.NewChat> { entry<Screen.NewChat> {
NewChatScreen() NewChatScreen()
@@ -214,66 +195,14 @@ fun App(viewModel: NostrViewModel) {
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,
)
}
}
}
}
} }
} }
} }

View File

@@ -4,23 +4,30 @@ import android.content.Intent
import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class ExternalSignerLauncher { class ExternalSignerLauncher {
private var launcher: ActivityResultLauncher<Intent>? = null private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null private var pendingResult: CompletableDeferred<ActivityResult>? = null
private val mutex = Mutex()
fun register(launcher: ActivityResultLauncher<Intent>) { fun register(launcher: ActivityResultLauncher<Intent>) {
this.launcher = launcher this.launcher = launcher
} }
suspend fun launch(intent: Intent): ActivityResult { suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
val deferred = CompletableDeferred<ActivityResult>() withContext(Dispatchers.Main) {
pendingResult = deferred val deferred = CompletableDeferred<ActivityResult>()
launcher?.launch(intent) pendingResult = deferred
?: throw IllegalStateException("ExternalSignerLauncher not registered") launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
return deferred.await() deferred.await()
}
} }
fun onResult(result: ActivityResult) { fun onResult(result: ActivityResult) {
pendingResult?.complete(result) pendingResult?.complete(result)
pendingResult = null pendingResult = null

View File

@@ -12,7 +12,7 @@ sealed interface Screen : NavKey {
return when (data.host) { return when (data.host) {
// Matches coop://chat/{id} // Matches coop://chat/{id}
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) } "chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) }
// Matches coop://profile/{pubkey} // Matches coop://profile/{pubkey}
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) } "profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
else -> null else -> null
@@ -24,11 +24,17 @@ sealed interface Screen : NavKey {
data object Home : Screen data object Home : Screen
@Serializable @Serializable
data class Chat(val id: Long) : Screen data object RequestList : Screen
@Serializable
data class Chat(val id: Long, val screening: Boolean = false) : Screen
@Serializable @Serializable
data class Profile(val pubkey: String) : Screen data class Profile(val pubkey: String) : Screen
@Serializable
data object ContactList : Screen
@Serializable @Serializable
data object UpdateProfile : Screen data object UpdateProfile : Screen

View File

@@ -22,6 +22,8 @@ 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 }
@@ -29,26 +31,26 @@ class NostrForegroundService : Service() {
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 {
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 if (notificationJob?.isActive == true) return START_STICKY
// Start the Nostr client
notificationJob = serviceScope.launch { notificationJob = serviceScope.launch {
try { try {
Log.d("Coop", "Starting Nostr in background") Log.d("Coop", "Starting Nostr in background")
@@ -63,8 +65,10 @@ class NostrForegroundService : Service() {
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e) throw IllegalStateException("Failed to initialize Nostr Client", e)
} }
// Connect to bootstrap relays // Connect to bootstrap relays
nostr.connectBootstrapRelays() nostr.connectBootstrapRelays()
// Handle notifications // Handle notifications
nostr.handleNotifications( nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata -> onMetadataUpdate = { pubkey, metadata ->
@@ -73,9 +77,6 @@ class NostrForegroundService : Service() {
onContactListUpdate = { contacts -> onContactListUpdate = { contacts ->
serviceScope.launch { nostr.emitContactListUpdate(contacts) } serviceScope.launch { nostr.emitContactListUpdate(contacts) }
}, },
onSubscriptionClose = {
serviceScope.launch { nostr.emitSubscriptionClosed() }
},
onNewMessage = { event -> onNewMessage = { event ->
serviceScope.launch { serviceScope.launch {
if (!isUserInApp()) { if (!isUserInApp()) {
@@ -93,6 +94,26 @@ class NostrForegroundService : Service() {
return START_STICKY return START_STICKY
} }
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)
@@ -115,10 +136,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")

View File

@@ -4,15 +4,11 @@ import androidx.compose.foundation.clickable
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.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
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.aspectRatio
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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
@@ -22,11 +18,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
@@ -36,50 +36,57 @@ import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
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.derivedStateOf 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
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.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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_cancel
import coop.composeapp.generated.resources.ic_check_circle
import coop.composeapp.generated.resources.ic_send import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first 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.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
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.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroupHeader
import su.reya.coop.humanReadable
import su.reya.coop.roomId 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.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@Composable @Composable
fun ChatScreen(id: Long) { fun ChatScreen(id: Long, screening: Boolean = false) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
// Get chat room by ID // Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
}
// Show empty screen // Show empty screen
if (room == null) { if (room == null) {
@@ -88,7 +95,7 @@ fun ChatScreen(id: Long) {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "Chat room not found", text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
) )
@@ -96,12 +103,13 @@ fun ChatScreen(id: Long) {
return return
} }
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(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) }
var requireScreening by remember { mutableStateOf(screening) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val messages = remember { mutableStateListOf<UnsignedEvent>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
@@ -119,21 +127,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) {
@@ -168,9 +167,7 @@ fun ChatScreen(id: Long) {
} }
) { ) {
if (loading) { if (loading) {
LoadingIndicator( LoadingIndicator(modifier = Modifier.size(32.dp))
modifier = Modifier.size(32.dp),
)
} else { } else {
Avatar( Avatar(
picture = picture, picture = picture,
@@ -221,66 +218,232 @@ fun ChatScreen(id: Long) {
.fillMaxSize() .fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding()) .padding(bottom = innerPadding.calculateBottomPadding())
) { ) {
if (messages.isNotEmpty()) { if (requireScreening) {
LazyColumn( room?.let { ScreenerCard(it) }
modifier = Modifier }
.weight(1f)
.fillMaxWidth(), when (messages.isNotEmpty()) {
contentPadding = PaddingValues(16.dp), true -> {
reverseLayout = true, LazyColumn(
state = listState, modifier = Modifier
) { .weight(1f)
groupedMessages.forEach { (dateHeader, messagesInGroup) -> .fillMaxWidth(),
items( contentPadding = PaddingValues(16.dp),
messagesInGroup, reverseLayout = true,
key = { it.id()?.toBech32()!! }) { event -> state = listState,
ChatMessage(event) ) {
} groupedMessages.forEach { (dateHeader, messagesInGroup) ->
item { items(
DateSeparator(dateHeader) items = messagesInGroup,
key = { it.id()?.toBech32() ?: it.hashCode() }
) {
ChatMessage(it)
}
item {
DateSeparator(dateHeader)
}
} }
} }
} }
} else {
Box( false -> {
modifier = Modifier Box(
.weight(1f) modifier = Modifier
.fillMaxWidth(), .weight(1f)
contentAlignment = Alignment.Center .fillMaxWidth(),
) { contentAlignment = Alignment.Center
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( Column(
text = "No messages yet", horizontalAlignment = Alignment.CenterHorizontally,
style = MaterialTheme.typography.titleLargeEmphasized.copy( verticalArrangement = Arrangement.spacedBy(4.dp),
fontWeight = FontWeight.SemiBold ) {
), Text(
color = MaterialTheme.colorScheme.onSurface text = "No messages yet",
) style = MaterialTheme.typography.titleLargeEmphasized.copy(
Text( fontWeight = FontWeight.SemiBold
text = "Your conversations will appear here.", ),
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface
color = MaterialTheme.colorScheme.outline )
) Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
} }
} }
} }
ChatInput(
value = text, when (requireScreening) {
onValueChange = { text = it }, true -> {
onSend = { Row(
viewModel.sendMessage(id, text) modifier = Modifier
text = "" .fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
modifier = Modifier.weight(1f)
) {
Text(
text = "Reject",
style = MaterialTheme.typography.titleMedium,
)
}
FilledTonalButton(
onClick = { requireScreening = false },
modifier = Modifier.weight(1f)
) {
Text(
text = "Accept",
style = MaterialTheme.typography.titleMedium,
)
}
}
} }
)
else -> {
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(id, text)
text = ""
}
)
}
}
} }
} }
} }
) )
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsStateWithLifecycle()
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name"
val picture = profile?.picture
LaunchedEffect(pubkey) {
scope.launch {
// Check contact
viewModel.verifyContact(pubkey).let { isContact = it }
// Get mutual contacts
viewModel.mutualContacts(pubkey).let { mutualContacts = it }
// Get the last activity
viewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 48.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
picture = picture,
description = "Profile picture",
modifier = Modifier.size(120.dp),
shape = MaterialShapes.Cookie12Sided.toShape(),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
Text(
text = pubkey.short(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
),
contentDescription = "Warning",
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Text(
text = if (isContact) "Contact" else "Not a contact",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
),
contentDescription = "Warning",
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Text(
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_check_circle),
contentDescription = "Warning",
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
Text(
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
}
}
}
@Composable @Composable
fun DateSeparator(date: String) { fun DateSeparator(date: String) {
Box( Box(
@@ -358,12 +521,9 @@ fun ChatInput(
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
onSend: () -> Unit onSend: () -> Unit
) { ) {
Surface(modifier = Modifier.fillMaxWidth()) { Surface(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
TextField( TextField(
@@ -380,9 +540,7 @@ fun ChatInput(
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton( FilledTonalIconButton(
onClick = onSend, onClick = onSend,
modifier = Modifier modifier = Modifier.size(56.dp),
.fillMaxHeight()
.aspectRatio(1f),
colors = IconButtonDefaults.filledTonalIconButtonColors( colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant contentColor = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -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
}
}

View File

@@ -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,15 +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 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
@@ -89,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.getExpressiveFontFamily
import su.reya.coop.shared.nameFlow
import su.reya.coop.shared.pictureFlow import su.reya.coop.shared.pictureFlow
import su.reya.coop.short import su.reya.coop.short
@@ -106,22 +115,26 @@ fun HomeScreen() {
val clipboardManager = LocalClipboard.current val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val currentUser = viewModel.currentUser() ?: return val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return val currentUserProfile = viewModel.getMetadata(currentUser)
val userProfile by currentUserProfile.collectAsStateWithLifecycle() val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle() val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false) val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState() val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } 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())
@@ -133,6 +146,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 { }
@@ -168,10 +186,18 @@ fun HomeScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
title = { title = {
Text( Row(
text = "Coop", verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleMediumEmphasized horizontalArrangement = Arrangement.spacedBy(8.dp)
) ) {
Text(
text = "Coop",
style = MaterialTheme.typography.titleMediumEmphasized
)
if (isSyncing) {
LoadingIndicator(modifier = Modifier.size(24.dp))
}
}
}, },
actions = { actions = {
// QR Scanner // QR Scanner
@@ -343,7 +369,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)) }
@@ -352,101 +382,330 @@ fun HomeScreen() {
} }
} }
} }
}
}
},
)
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showBottomSheet = false }, onDismissRequest = { showBottomSheet = false },
sheetState = sheetState, sheetState = sheetState,
modifier = Modifier ) {
.imePadding() val pubkey = viewModel.currentUser()
.navigationBarsPadding(), val shortPubkey = pubkey?.short() ?: "Not available"
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName = val userName =
userProfile?.asRecord()?.displayName userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name ?: userProfile?.asRecord()?.name
?: "No name" ?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action -> val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
}
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch { scope.launch {
sheetState.hide() pubkey?.let {
showBottomSheet = false val bech32 = it.toBech32()
action() val data =
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
} }
} },
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
Column( // Show the relay setup dialog if the msg relay list is empty
modifier = Modifier if (isRelayListEmpty) {
.padding(16.dp) ModalBottomSheet(
.fillMaxWidth(), onDismissRequest = { viewModel.dismissRelayWarning() },
) { sheetState = sheetState,
Column( containerColor = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth(), ) {
horizontalAlignment = Alignment.CenterHorizontally, Column(
) { modifier = Modifier
Box( .fillMaxWidth()
modifier = Modifier .fillMaxHeight(0.5f)
.size(84.dp) .padding(horizontal = 24.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()), .navigationBarsPadding(),
contentAlignment = Alignment.Center horizontalAlignment = Alignment.CenterHorizontally,
) { verticalArrangement = Arrangement.spacedBy(16.dp),
Avatar( ) {
picture = userProfile?.asRecord()?.picture, Text(
description = userProfile?.asRecord()?.displayName, text = "Messaging Relays are missing",
shape = MaterialShapes.Cookie9Sided.toShape(), style = MaterialTheme.typography.titleLargeEmphasized.copy(
modifier = Modifier.fillMaxSize() fontWeight = FontWeight.SemiBold,
) fontFamily = getExpressiveFontFamily()
} ),
Spacer(modifier = Modifier.size(8.dp)) textAlign = TextAlign.Center,
Box( color = MaterialTheme.colorScheme.primary,
contentAlignment = Alignment.Center )
) { Row(
Text( modifier = Modifier.fillMaxWidth(),
text = userName, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.titleLargeEmphasized, horizontalArrangement = Arrangement.spacedBy(8.dp),
) ) {
} Surface(
Spacer(modifier = Modifier.size(8.dp)) modifier = Modifier.size(24.dp),
Row( shape = MaterialShapes.Circle.toShape(),
verticalAlignment = Alignment.CenterVertically, color = MaterialTheme.colorScheme.error,
) { ) {
OutlinedButton( Box(
onClick = { modifier = Modifier.fillMaxSize(),
scope.launch { contentAlignment = Alignment.Center,
pubkey?.let { ) {
val bech32 = it.toBech32() Icon(
val data = painter = painterResource(Res.drawable.ic_close),
ClipData.newPlainText(bech32, bech32) contentDescription = "X",
clipboardManager.setClipEntry(ClipEntry(data)) modifier = Modifier.size(16.dp),
} tint = MaterialTheme.colorScheme.onError
} )
}, }
) { }
Text(text = shortPubkey) Text(
} text = "Other people won't be able to send you messages.",
FilledIconButton( style = MaterialTheme.typography.titleSmallEmphasized,
onClick = { )
dismissAndRun { navigator.navigate(Screen.MyQr) } }
}, Row(
shape = MaterialShapes.Square.toShape() modifier = Modifier.fillMaxWidth(),
) { verticalAlignment = Alignment.CenterVertically,
Icon( horizontalArrangement = Arrangement.spacedBy(8.dp),
painter = painterResource(Res.drawable.ic_qr), ) {
contentDescription = "My QR" 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
} }
Spacer(modifier = Modifier.size(16.dp)) },
BottomMenuList(onDismiss = dismissAndRun) 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?.nameFlow(viewModel) ?: flowOf("")
}.collectAsStateWithLifecycle("Loading...")
val secondName by remember(secondRoom) {
secondRoom?.nameFlow(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
)
) )
} }
@@ -454,8 +713,8 @@ fun HomeScreen() {
@Composable @Composable
fun ChatRoom(room: Room, onClick: () -> Unit) { fun ChatRoom(room: Room, onClick: () -> Unit) {
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...") val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null) val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
ListItem( ListItem(
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier.clickable(onClick = onClick),
@@ -483,7 +742,9 @@ 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,
maxLines = 1,
) )
} }
}, },
@@ -503,8 +764,7 @@ fun BottomMenuList(
val defaultMenuList = listOf( val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) }, "Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { }, "Contact List" to { navigator.navigate(Screen.ContactList) },
"Spams & Blocks" to { },
"Relay Management" to { navigator.navigate(Screen.Relay) }, "Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { } "Settings" to { }
) )
@@ -530,7 +790,7 @@ fun BottomMenuList(
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton( FilledTonalButton(
onClick = { viewModel.logout() }, onClick = { onDismiss { viewModel.logout() } },
colors = ButtonDefaults.filledTonalButtonColors( colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error, containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError contentColor = MaterialTheme.colorScheme.onError

View File

@@ -94,17 +94,18 @@ fun ImportScreen() {
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
runCatching { runCatching {
if (result.startsWith("nsec")) { if (result.startsWith("nsec1")) {
Keys.parse(result) Keys.parse(result)
} else if (result.startsWith("bunker://")) { } else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result) NostrConnectUri.parse(result)
} else { } else {
throw IllegalArgumentException("Invalid secret: $result") throw IllegalArgumentException("Unsupported secret format")
} }
}.onSuccess {
secret = result
}.onFailure { e ->
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
} }
.onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state // Clear the nav state
qrScanResult.clear() qrScanResult.clear()
} }

View File

@@ -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 {
@@ -108,14 +109,13 @@ fun NewChatScreen() {
LaunchedEffect(qrScanResult.content) { LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result -> qrScanResult.content?.let { result ->
// Verify the content // Verify the content
runCatching { PublicKey.parse(result) } runCatching {
.onSuccess { pubkey -> PublicKey.parse(result)
selectedReceivers.add(pubkey) }.onSuccess { pubkey ->
} selectedReceivers.add(pubkey)
.onFailure { e -> }.onFailure { e ->
println("Failed to parse QR: ${e.message}") snackbarHostState.showSnackbar("Invalid address: ${e.message}")
} }
// Clear the nav state // Clear the nav state
qrScanResult.clear() qrScanResult.clear()
} }

View File

@@ -27,7 +27,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -37,6 +36,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
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.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_chat import coop.composeapp.generated.resources.ic_chat
@@ -55,26 +55,27 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ProfileScreen(pubkey: String) { fun ProfileScreen(pubkey: String) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current 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 viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null) val metadata by metadataFlow.collectAsStateWithLifecycle()
val profile = metadata?.asRecord() val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name" val displayName = profile?.displayName ?: profile?.name ?: "No name"
val nip05 = profile?.nip05 ?: pubkey.short() val nip05 = profile?.nip05 ?: pubkey.short()
val picture = profile?.picture val picture = profile?.picture
val details = remember(profile) { val details = remember(profile) {
listOf( listOf(
"Username:" to (profile?.name ?: "None"), "Username:" to (profile?.name ?: "None"),
"Website:" to (profile?.website ?: "None"), "Website:" to (profile?.website ?: "None"),
"Lightning Address:" to (profile?.lud16 ?: "None"), "Lightning Address:" to (profile?.lud16 ?: "None"),
) )
} }
@@ -83,7 +84,7 @@ fun ProfileScreen(pubkey: String) {
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { }, title = { /* empty */ },
navigationIcon = { navigationIcon = {
IconButton(onClick = { navigator.goBack() }) { IconButton(onClick = { navigator.goBack() }) {
Icon( Icon(

View File

@@ -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
}
}

View File

@@ -0,0 +1,158 @@
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(
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, true)) }
)
}
}
}
}
}
}
}
}

View File

@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -44,10 +43,10 @@ fun ScanScreen() {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( CenterAlignedTopAppBar(
title = { title = {
Text( Text(
text = "Scan QR", text = "Scan a Nostr Address",
style = MaterialTheme.typography.titleMediumEmphasized style = MaterialTheme.typography.titleMediumEmphasized
) )
}, },
@@ -97,14 +96,6 @@ fun ScanScreen() {
.align(Alignment.Center) .align(Alignment.Center)
.border(2.dp, Color.White, RoundedCornerShape(12.dp)) .border(2.dp, Color.White, RoundedCornerShape(12.dp))
) )
Text(
text = "Scan a Nostr address",
style = MaterialTheme.typography.titleSmallEmphasized,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 64.dp)
)
} }
} }
} }

View File

@@ -8,7 +8,7 @@ import su.reya.coop.NostrViewModel
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.short import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> { fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
// Return early if there's a custom subject/room name // Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) } subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
@@ -18,8 +18,8 @@ fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray -> return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata -> val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord() val profile = metadata?.asRecord()
profile?.name?.takeIf { it.isNotBlank() } profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() } ?: profile?.name?.takeIf { it.isNotBlank() }
?: displayMembers[i].short() ?: displayMembers[i].short()
} }

View File

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

View File

@@ -4,12 +4,15 @@ import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AckPolicy import rust.nostr.sdk.AckPolicy
@@ -50,13 +53,12 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.UnwrappedGift
import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.initLogger import rust.nostr.sdk.initLogger
import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync import rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager { object NostrManager {
val instance = Nostr() val instance = Nostr()
@@ -78,8 +80,6 @@ class Nostr {
private set private set
var signer: UniversalSigner = UniversalSigner(Keys.generate()) var signer: UniversalSigner = UniversalSigner(Keys.generate())
private set private set
var deviceSigner: AsyncNostrSigner? = null
private set
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf() var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
private set private set
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf() var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
@@ -97,18 +97,20 @@ class Nostr {
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100) private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow() val contactListUpdates = _contactListUpdates.asSharedFlow()
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10) private val _messageSyncState = MutableStateFlow(MessageSyncState())
val subscriptionClosed = _subscriptionClosed.asSharedFlow() val messageSyncState = _messageSyncState.asStateFlow()
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event) suspend fun emitNewEvent(event: UnsignedEvent) {
_newEvents.emit(event)
}
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit) suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
_metadataUpdates.emit(pubkey to metadata) _metadataUpdates.emit(pubkey to metadata)
}
suspend fun emitContactListUpdate(contacts: List<PublicKey>) = suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
_contactListUpdates.emit(contacts) _contactListUpdates.emit(contacts)
}
suspend fun init( suspend fun init(
dbPath: String, dbPath: String,
@@ -190,9 +192,12 @@ class Nostr {
} }
} }
suspend fun exit() { suspend fun prune() {
signer.switch(Keys.generate()) try {
deviceSigner = null client?.database()?.wipe()
} catch (e: Exception) {
throw IllegalStateException("Failed to prune database: ${e.message}", e)
}
} }
suspend fun setSigner(new: AsyncNostrSigner) { suspend fun setSigner(new: AsyncNostrSigner) {
@@ -265,17 +270,39 @@ class Nostr {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun handleNotifications( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit, onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit, onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit,
) = supervisorScope { ) = supervisorScope {
val now = Timestamp.now() val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>() val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope val notifications = client?.notifications() ?: return@supervisorScope
var eoseTrackerJob: Job? = null val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
var processedCount = 0
var eoseReceived = false
launch(Dispatchers.Default) {
for (event in giftWrapQueue) {
val rumor = extractRumor(event) ?: continue
processedCount++
// Trigger new message notification
if (rumor.createdAt().asSecs() >= now.asSecs()) {
onNewMessage(rumor)
}
// Update sync state
_messageSyncState.update {
it.copy(
processedCount = processedCount,
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
)
}
}
}
while (true) { while (true) {
val notification = notifications.next() ?: continue val notification = notifications.next() ?: continue
@@ -292,58 +319,47 @@ class Nostr {
if (processedEvent.contains(event.id())) continue if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id()) processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) { when (event.kind().asStd()) {
try { KindStandard.METADATA -> {
val metadata = Metadata.fromJson(event.content()) try {
onMetadataUpdate(event.author(), metadata) val metadata = Metadata.fromJson(event.content())
} catch (e: Exception) { onMetadataUpdate(event.author(), metadata)
println("Failed to parse metadata: $e") } catch (e: Exception) {
} println("Failed to parse metadata: $e")
}
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
if (isSignedByUser(event = event)) {
onContactListUpdate(event.tags().publicKeys())
}
}
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event)
}
}
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
val rumor = extractRumor(event)
// Logic to notify UI after processing
// Cancel previous tracker if it exists
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
} }
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
} catch (e: Exception) {
println("Failed to extract rumor: $e")
} }
KindStandard.CONTACT_LIST -> {
if (isSignedByUser(event = event)) {
val pubkeys = event.tags().publicKeys()
// Get mutual contacts
getMutualContacts(pubkeys)
// Emit contact list update
onContactListUpdate(pubkeys)
}
}
KindStandard.INBOX_RELAYS -> {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event)
}
}
KindStandard.GIFT_WRAP -> {
giftWrapQueue.send(event)
}
else -> {}
} }
} }
is RelayMessageEnum.EndOfStoredEvents -> { is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId if (message.subscriptionId == "gift-wraps") {
eoseReceived = true
if (subscriptionId == "gift-wraps") { if (giftWrapQueue.isEmpty) {
onSubscriptionClose() _messageSyncState.update { it.copy(isSyncing = false) }
}
} }
} }
@@ -371,77 +387,115 @@ class Nostr {
} }
} }
private suspend fun getMutualContacts(pubkeys: List<PublicKey>) {
try {
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).authors(pubkeys).limit(200u)
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
client?.subscribe(
target = ReqTarget.manual(target),
id = "mutual-contacts",
closeOn = opts,
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
}
}
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? { private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
try { try {
val filter = Filter().identifier(giftId.toHex()) val filter = Filter().identifier(giftId.toHex())
val event = client?.database()?.query(filter)?.first() val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it) } return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) { } catch (e: Throwable) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
} }
} }
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try { try {
val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
// Construct the room id
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.event(rumor.id()!!), Tag.publicKey(rumor.author()),
Tag.custom("a", listOf(roomId.toString())), Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("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)
.finalizeUnsigned(currentUser) .finalizeAsync(Keys.generate())
.signAsync(Keys.generate())
client?.database()?.saveEvent(event) client?.database()?.saveEvent(event)
} catch (e: Exception) { } catch (e: Throwable) {
println("Failed to set cached rumor: ${e.message}") println("Failed to set cached rumor: ${e.message}")
} }
} }
private suspend fun extractRumor(event: Event): UnsignedEvent? { private suspend fun extractRumor(event: Event): UnsignedEvent? {
// Check if the rumor is already cached try {
val cachedRumor = getCachedRumor(event.id()) // Gift wrap must have at least one 'p' tag
if (cachedRumor != null) return cachedRumor if (event.tags().publicKeys().isEmpty()) {
println("No recipient tags found.")
// Get all signers return null
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 rumor = gift.rumor()
// Save the rumor to the database
setCachedRumor(event.id(), rumor)
// Return the rumor
return rumor
} catch (e: Exception) {
println("Failed to unwrap gift: ${e.message}")
continue
} }
}
return null // Event must be a gift wrap
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
println("Event is not a gift wrap.")
return null
}
// Check if the rumor is already cached
val cachedRumor = getCachedRumor(event.id())
if (cachedRumor != null) return cachedRumor
// Decrypt the gift wrap event
val seal = signer.nip44DecryptAsync(event.author(), event.content())
val sealEvent = Event.fromJson(seal)
// Verify seal event
if (!sealEvent.verify()) {
println("Failed to verify seal event.")
return null
}
// Decrypt the rumor
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
// Ensure the rumor author matches the seal
if (unsignedEvent.author() != sealEvent.author()) {
println("Author mismatch.")
return null
}
// Cache the rumor for later use
setCachedRumor(event.id(), unsignedEvent)
return unsignedEvent
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
return null
}
} }
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> { private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
// Construct a list of relays // Construct a list of relays
val relayList = mapOf<RelayUrl, RelayMetadata>( val relayList = mapOf(
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
@@ -453,9 +507,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)
} }
@@ -466,7 +521,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"),
) )
@@ -482,7 +537,7 @@ class Nostr {
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event // Send relay list event
val relayList = getDefaultRelayList() val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys); val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
client?.sendEvent( client?.sendEvent(
event = relayListEvent, event = relayListEvent,
@@ -557,7 +612,7 @@ class Nostr {
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try { return try {
val kind = Kind.fromStd(KindStandard.METADATA); val kind = Kind.fromStd(KindStandard.METADATA)
val filter = Filter().kind(kind).author(pubkey).limit(1u) val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null val event = client?.database()?.query(filter)?.first() ?: return null
@@ -592,7 +647,7 @@ class Nostr {
suspend fun fetchMetadataBatch(keys: List<PublicKey>) { suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
try { try {
val limit = keys.size.toULong() * 4u; val limit = keys.size.toULong() * 4u
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
// Construct a filter for metadata events // Construct a filter for metadata events
@@ -626,7 +681,7 @@ class Nostr {
ackPolicy = AckPolicy.none(), ackPolicy = AckPolicy.none(),
) )
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS); val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u) val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
val target = ReqTarget.auto(listOf(filter)) val target = ReqTarget.auto(listOf(filter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
@@ -649,6 +704,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)
@@ -661,14 +729,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 DM events
val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm")) val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter) val events = client?.database()?.query(filter)
// Collect rooms // Collect rooms
@@ -684,11 +783,13 @@ 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 rTag = SingleLetterTag.lowercase(Alphabet.R)
Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList()) val filter = Filter().kind(kind).pubkey(userPubkey)
.customTag(rTag, newRoom.id.toString())
// 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)?.toVec()?.isNotEmpty() ?: false
// Append room to map // Append room to map
roomsMap[newRoom.id] = roomsMap[newRoom.id] =
@@ -712,7 +813,7 @@ class Nostr {
// Merge the events // Merge the events
return events return events
?.toVec() ?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) } ?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
// Filter out events without public keys (receivers) // Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() } ?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList() ?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
@@ -721,33 +822,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)
} }
@@ -755,12 +848,10 @@ class Nostr {
suspend fun connectMsgRelays(event: Event) { suspend fun connectMsgRelays(event: Event) {
try { try {
val urls = nip17ExtractRelayList(event); val urls = nip17ExtractRelayList(event)
for (url in urls) { for (url in urls) {
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)
@@ -776,7 +867,7 @@ 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>()
@@ -803,6 +894,7 @@ class Nostr {
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content) val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
.tags(tags) .tags(tags)
.finalizeUnsigned(currentUser) .finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen // Emit the rumor to the chat screen
if (receiver == currentUser) { if (receiver == currentUser) {
@@ -900,4 +992,66 @@ class Nostr {
throw IllegalStateException("Failed to search nostr: ${e.message}", e) throw IllegalStateException("Failed to search nostr: ${e.message}", e)
} }
} }
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
try {
val filter = Filter().author(pubkey).limit(3u)
val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
val events = client?.fetchEvents(
target = ReqTarget.manual(target),
timeout = Duration.parse("3s")
)
return events?.first()?.createdAt()
} catch (e: Exception) {
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).author(currentUser).limit(1u)
val events = client?.database()?.query(filter)
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
return pubkeys.contains(pubkey)
} catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
val events = client?.database()?.query(filter)
val contacts = mutableSetOf<PublicKey>()
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
contacts.add(event.author())
}
return contacts.toSet()
} catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
}
}
} }
data class MessageSyncState(
val processedCount: Int = 0,
val isSyncing: Boolean = false
)

View File

@@ -13,10 +13,13 @@ 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
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -35,6 +38,7 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.blossom.BlossomClient import su.reya.coop.blossom.BlossomClient
import su.reya.coop.storage.SecretStorage import su.reya.coop.storage.SecretStorage
@@ -78,9 +82,14 @@ class NostrViewModel(
val errorEvents = _errorEvents.receiveAsFlow() val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>() private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED) private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>() private val seenPublicKeys = mutableSetOf<PublicKey>()
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init { init {
// Skip the splash screen if a user is already logged in // Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) { if (nostr.signer.currentUser != null) {
@@ -107,7 +116,6 @@ class NostrViewModel(
viewModelScope.launch { viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
coroutineScope { coroutineScope {
launch { refreshChatRooms() }
launch { runObserver() } launch { runObserver() }
launch { runMetadataBatching() } launch { runMetadataBatching() }
} }
@@ -146,21 +154,22 @@ class NostrViewModel(
} }
} }
private fun processIncomingEvent(event: UnsignedEvent) {
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) {
nostr.signer.currentUser?.let { user ->
val newRoom = Room.new(event, user)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
}
private suspend fun runObserver() = coroutineScope { private suspend fun runObserver() = coroutineScope {
// Observe message sync progress
launch {
nostr.messageSyncState.collect { state ->
// When at least some messages are processed, allow UI to show the list
if (state.processedCount > 0) {
_isPartialProcessedGiftWrap.value = true
}
// Refresh UI every 10 messages OR when sync is fully done
if (state.processedCount % 10 == 0 || !state.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages // Observe new messages
launch { launch {
nostr.newEvents.collect { event -> nostr.newEvents.collect { event ->
@@ -194,14 +203,6 @@ class NostrViewModel(
updateMetadata(pubkey, metadata) updateMetadata(pubkey, metadata)
} }
} }
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
} }
private suspend fun runMetadataBatching() = coroutineScope { private suspend fun runMetadataBatching() = coroutineScope {
@@ -298,13 +299,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
} }
@@ -340,12 +339,34 @@ class NostrViewModel(
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
secretStore.clear("user_signer") try {
nostr.signer.switch(Keys.generate()) _isBusy.value = true
_signerRequired.value = true // Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
secretStore.clear("user_signer")
// Reset all UI states
resetInternalState()
_isBusy.value = false
_signerRequired.value = true
}
} }
} }
private fun resetInternalState() {
_chatRooms.value = emptySet()
_contactList.value = emptySet()
_isPartialProcessedGiftWrap.value = false
_isRelayListEmpty.value = false
_isNotificationBannerDismissed.value = false
}
fun dismissNotificationBanner() { fun dismissNotificationBanner() {
viewModelScope.launch { viewModelScope.launch {
secretStore.set("notification_banner_dismissed", "true") secretStore.set("notification_banner_dismissed", "true")
@@ -540,6 +561,11 @@ class NostrViewModel(
return externalSignerHandler?.isAvailable() == true 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()
@@ -558,6 +584,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!!)
@@ -567,6 +629,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")
@@ -644,20 +778,16 @@ class NostrViewModel(
return emptyList() return emptyList()
} }
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> { fun chatRoomConnect(roomId: Long) {
try { viewModelScope.launch {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") try {
val members = room.members val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
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() }
} }
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
} }
} }
@@ -727,6 +857,33 @@ class NostrViewModel(
} }
return emptyList() return emptyList()
} }
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try {
nostr.verifyActivity(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
return try {
nostr.verifyContact(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
false
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
return try {
nostr.mutualContacts(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
setOf()
}
}
} }
fun PublicKey.short(): String { fun PublicKey.short(): String {

70
zapstore.yaml Normal file
View File

@@ -0,0 +1,70 @@
# ═══════════════════════════════════════════════════════════════════
# SOURCE CONFIGURATION
# ═══════════════════════════════════════════════════════════════════
repository: https://git.reya.su/reya/coop-mobile
release_source:
url: https://git.reya.su/reya/coop-mobile
type: gitea
# ═══════════════════════════════════════════════════════════════════
# APP METADATA
# ═══════════════════════════════════════════════════════════════════
name: Coop
summary: Chat and Co-op Privately
license: GPLv3
website: https://reya.su/coop
description: |
Coop is a simple, cozy chat app built for Nostr. Its designed for folks who want to stay connected without sacrificing their privacy or control over their data.
**Features**:
- Private & Secure: Enjoy end-to-end encrypted messaging that keeps your private chats truly private.
- Friendly Socializing: Connect with friends across the open Nostr network in a clean, easy-to-use interface.
- No Identity Required: Get started without a phone number. Stay anonymous, use a pseudonym, or even your real name.
tags:
- messaging
- privacy
- material3
- nostr
# ═══════════════════════════════════════════════════════════════════
# MEDIA
# ═══════════════════════════════════════════════════════════════════
icon: ./assets/icon.png
images:
- ./assets/screenshot1.png
- ./assets/screenshot2.png
- ./assets/screenshot3.png
- ./assets/screenshot4.png
- ./assets/screenshot5.png
# ═══════════════════════════════════════════════════════════════════
# RELEASE CONFIGURATION
# ═══════════════════════════════════════════════════════════════════
release_notes: ./CHANGELOG.md
# ═══════════════════════════════════════════════════════════════════
# NOSTR-SPECIFIC
# ═══════════════════════════════════════════════════════════════════
supported_nips:
- "01"
- "17"
- "46"
- "55"
- "59"
min_allowed_version_code: 100
# ═══════════════════════════════════════════════════════════════════
# METADATA SOURCES
# ═══════════════════════════════════════════════════════════════════
metadata_sources:
- playstore