Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26f9e577f4 | |||
| dd416711e2 | |||
| b90279e174 | |||
| 25ba03562a | |||
| 010a8b4060 | |||
| 750d46bc9e | |||
| 790d24c24b | |||
| 4fbc8446d3 | |||
| b4518ce360 | |||
| 0f4bb71a39 | |||
| 37003e2fc4 | |||
| 154906017e | |||
| 91e4e3b43d | |||
| ea90a43909 | |||
| 938b192136 |
93
CHANGELOG.md
Normal file
93
CHANGELOG.md
Normal 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
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/screenshot1.png
Normal file
BIN
assets/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
assets/screenshot2.png
Normal file
BIN
assets/screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 444 KiB |
BIN
assets/screenshot3.png
Normal file
BIN
assets/screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
BIN
assets/screenshot4.png
Normal file
BIN
assets/screenshot4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/screenshot5.png
Normal file
BIN
assets/screenshot5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -24,7 +24,7 @@ kotlin {
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.1")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||
@@ -69,7 +69,7 @@ android {
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||
versionCode = 1
|
||||
versionName = "0.1.9"
|
||||
versionName = "0.2.1"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<queries>
|
||||
@@ -59,7 +59,7 @@
|
||||
android:name=".NostrForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
android:foregroundServiceType="remoteMessaging" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -16,14 +16,12 @@ import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.expressiveLightColorScheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -37,6 +35,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import su.reya.coop.screens.ContactListScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
import su.reya.coop.screens.MyQrScreen
|
||||
@@ -45,6 +44,7 @@ import su.reya.coop.screens.NewIdentityScreen
|
||||
import su.reya.coop.screens.OnboardingScreen
|
||||
import su.reya.coop.screens.ProfileScreen
|
||||
import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.RequestListScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
import su.reya.coop.screens.UpdateProfileScreen
|
||||
|
||||
@@ -69,8 +69,6 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||
fun App(viewModel: NostrViewModel) {
|
||||
val context = LocalContext.current
|
||||
val activity = context as? ComponentActivity
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val backStack = rememberNavBackStack(Screen.Home)
|
||||
val navigator = remember(backStack) { Navigator(backStack) }
|
||||
val qrScanResult = remember { QrScanResult() }
|
||||
@@ -167,6 +165,9 @@ fun App(viewModel: NostrViewModel) {
|
||||
entry<Screen.Home> {
|
||||
HomeScreen()
|
||||
}
|
||||
entry<Screen.RequestList> {
|
||||
RequestListScreen()
|
||||
}
|
||||
entry<Screen.Onboarding> {
|
||||
OnboardingScreen()
|
||||
}
|
||||
@@ -177,7 +178,7 @@ fun App(viewModel: NostrViewModel) {
|
||||
NewIdentityScreen()
|
||||
}
|
||||
entry<Screen.Chat> { key ->
|
||||
ChatScreen(id = key.id)
|
||||
ChatScreen(id = key.id, screening = key.screening)
|
||||
}
|
||||
entry<Screen.NewChat> {
|
||||
NewChatScreen()
|
||||
@@ -194,6 +195,9 @@ fun App(viewModel: NostrViewModel) {
|
||||
entry<Screen.MyQr> {
|
||||
MyQrScreen()
|
||||
}
|
||||
entry<Screen.ContactList> {
|
||||
ContactListScreen()
|
||||
}
|
||||
entry<Screen.Relay> {
|
||||
RelayScreen()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ sealed interface Screen : NavKey {
|
||||
|
||||
return when (data.host) {
|
||||
// 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}
|
||||
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||
else -> null
|
||||
@@ -24,11 +24,17 @@ sealed interface Screen : NavKey {
|
||||
data object Home : Screen
|
||||
|
||||
@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
|
||||
data class Profile(val pubkey: String) : Screen
|
||||
|
||||
@Serializable
|
||||
data object ContactList : Screen
|
||||
|
||||
@Serializable
|
||||
data object UpdateProfile : Screen
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||
|
||||
class NostrForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val nostr by lazy { NostrManager.instance }
|
||||
@@ -29,26 +31,26 @@ class NostrForegroundService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun isUserInApp(): Boolean {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
createNotificationChannel()
|
||||
val notification = createNotification()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(1, notification)
|
||||
}
|
||||
startAsForeground()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Start the Nostr client
|
||||
notificationJob = serviceScope.launch {
|
||||
try {
|
||||
Log.d("Coop", "Starting Nostr in background")
|
||||
@@ -63,8 +65,10 @@ class NostrForegroundService : Service() {
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||
}
|
||||
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
|
||||
// Handle notifications
|
||||
nostr.handleNotifications(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
@@ -73,9 +77,6 @@ class NostrForegroundService : Service() {
|
||||
onContactListUpdate = { contacts ->
|
||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
||||
},
|
||||
onSubscriptionClose = {
|
||||
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
||||
},
|
||||
onNewMessage = { event ->
|
||||
serviceScope.launch {
|
||||
if (!isUserInApp()) {
|
||||
@@ -93,6 +94,26 @@ class NostrForegroundService : Service() {
|
||||
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() {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
|
||||
@@ -115,10 +136,12 @@ class NostrForegroundService : Service() {
|
||||
|
||||
private fun createNotification(content: String? = null): Notification {
|
||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||
.setGroup(GROUP_KEY_MESSAGES)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||
.setCategory(Notification.CATEGORY_SERVICE)
|
||||
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||
|
||||
if (content != null) {
|
||||
builder.setContentTitle("Coop")
|
||||
|
||||
@@ -4,15 +4,11 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
@@ -22,11 +18,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Badge
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -36,39 +36,50 @@ import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_cancel
|
||||
import coop.composeapp.generated.resources.ic_check_circle
|
||||
import coop.composeapp.generated.resources.ic_send
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.LocalNavigator
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.formatAsGroupHeader
|
||||
import su.reya.coop.humanReadable
|
||||
import su.reya.coop.roomId
|
||||
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.short
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(id: Long) {
|
||||
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
@@ -92,12 +103,13 @@ fun ChatScreen(id: Long) {
|
||||
return
|
||||
}
|
||||
|
||||
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
||||
val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||
|
||||
var text by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||
var requireScreening by remember { mutableStateOf(screening) }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||
@@ -155,9 +167,7 @@ fun ChatScreen(id: Long) {
|
||||
}
|
||||
) {
|
||||
if (loading) {
|
||||
LoadingIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||
} else {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
@@ -208,66 +218,232 @@ fun ChatScreen(id: Long) {
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
if (messages.isNotEmpty()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
reverseLayout = true,
|
||||
state = listState,
|
||||
) {
|
||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
messagesInGroup,
|
||||
key = { it.id()?.toBech32()!! }) { event ->
|
||||
ChatMessage(event)
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
if (requireScreening) {
|
||||
room?.let { ScreenerCard(it) }
|
||||
}
|
||||
|
||||
when (messages.isNotEmpty()) {
|
||||
true -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
reverseLayout = true,
|
||||
state = listState,
|
||||
) {
|
||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||
items(
|
||||
items = messagesInGroup,
|
||||
key = { it.id()?.toBech32() ?: it.hashCode() }
|
||||
) {
|
||||
ChatMessage(it)
|
||||
}
|
||||
item {
|
||||
DateSeparator(dateHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
||||
false -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No messages yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatInput(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(id, text)
|
||||
text = ""
|
||||
|
||||
when (requireScreening) {
|
||||
true -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.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
|
||||
fun DateSeparator(date: String) {
|
||||
Box(
|
||||
@@ -345,12 +521,9 @@ fun ChatInput(
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
|
||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.height(IntrinsicSize.Min),
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
TextField(
|
||||
@@ -367,9 +540,7 @@ fun ChatInput(
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = onSend,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f),
|
||||
modifier = Modifier.size(56.dp),
|
||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
@@ -84,7 +85,9 @@ import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_close
|
||||
import coop.composeapp.generated.resources.ic_new_chat
|
||||
import coop.composeapp.generated.resources.ic_qr
|
||||
import coop.composeapp.generated.resources.ic_request
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
@@ -93,11 +96,12 @@ import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalScanResult
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.RoomKind
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.shared.Avatar
|
||||
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.short
|
||||
|
||||
@@ -111,20 +115,22 @@ fun HomeScreen() {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
||||
|
||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState(true)
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUserProfile = viewModel.getMetadata(currentUser)
|
||||
|
||||
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||
val isSyncing by viewModel.isSyncing.collectAsStateWithLifecycle()
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
|
||||
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -140,6 +146,11 @@ fun HomeScreen() {
|
||||
// State will be updated by LifecycleResumeEffect
|
||||
}
|
||||
|
||||
// Partition chat rooms into requests and ongoing
|
||||
val (requests, ongoing) = remember(chatRooms) {
|
||||
chatRooms.partition { it.kind == RoomKind.Request }
|
||||
}
|
||||
|
||||
LifecycleResumeEffect(context) {
|
||||
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||
onPauseOrDispose { }
|
||||
@@ -175,10 +186,18 @@ fun HomeScreen() {
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
if (isSyncing) {
|
||||
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// QR Scanner
|
||||
@@ -350,7 +369,11 @@ fun HomeScreen() {
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
||||
if (requests.isNotEmpty()) {
|
||||
item { NewRequests(requests) }
|
||||
}
|
||||
|
||||
items(ongoing, key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||
@@ -603,12 +626,95 @@ fun HomeScreen() {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
@@ -636,7 +742,9 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
if (!room.lastMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = room.lastMessage!!,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -656,8 +764,7 @@ fun BottomMenuList(
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||
"Contact List" to { },
|
||||
"Spams & Blocks" to { },
|
||||
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||
"Settings" to { }
|
||||
)
|
||||
@@ -683,7 +790,7 @@ fun BottomMenuList(
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.logout() },
|
||||
onClick = { onDismiss { viewModel.logout() } },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
|
||||
@@ -94,17 +94,18 @@ fun ImportScreen() {
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
runCatching {
|
||||
if (result.startsWith("nsec")) {
|
||||
if (result.startsWith("nsec1")) {
|
||||
Keys.parse(result)
|
||||
} else if (result.startsWith("bunker://")) {
|
||||
NostrConnectUri.parse(result)
|
||||
} 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
|
||||
qrScanResult.clear()
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.short
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@@ -78,7 +79,7 @@ fun NewChatScreen() {
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.length >= 3) {
|
||||
delay(500) // 500ms debounce
|
||||
delay(500.milliseconds)
|
||||
|
||||
if (query.startsWith("npub1")) {
|
||||
val pubkey = try {
|
||||
@@ -108,14 +109,13 @@ fun NewChatScreen() {
|
||||
LaunchedEffect(qrScanResult.content) {
|
||||
qrScanResult.content?.let { result ->
|
||||
// Verify the content
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
.onFailure { e ->
|
||||
println("Failed to parse QR: ${e.message}")
|
||||
}
|
||||
|
||||
runCatching {
|
||||
PublicKey.parse(result)
|
||||
}.onSuccess { pubkey ->
|
||||
selectedReceivers.add(pubkey)
|
||||
}.onFailure { e ->
|
||||
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||
}
|
||||
// Clear the nav state
|
||||
qrScanResult.clear()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_chat
|
||||
@@ -55,26 +55,27 @@ import su.reya.coop.short
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ProfileScreen(pubkey: String) {
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navigator = LocalNavigator.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
|
||||
val details = remember(profile) {
|
||||
listOf(
|
||||
"Username:" to (profile?.name ?: "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) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { },
|
||||
title = { /* empty */ },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { navigator.goBack() }) {
|
||||
Icon(
|
||||
|
||||
@@ -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)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,10 +43,10 @@ fun ScanScreen() {
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Scan QR",
|
||||
text = "Scan a Nostr Address",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
@@ -97,14 +96,6 @@ fun ScanScreen() {
|
||||
.align(Alignment.Center)
|
||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Nostr address",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import su.reya.coop.NostrViewModel
|
||||
import su.reya.coop.Room
|
||||
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
|
||||
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 ->
|
||||
val names = metadataArray.mapIndexed { i, metadata ->
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kotlin {
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.3.1")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
}
|
||||
androidMain.dependencies {
|
||||
|
||||
@@ -4,12 +4,15 @@ import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import rust.nostr.sdk.AckPolicy
|
||||
@@ -50,13 +53,12 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import rust.nostr.sdk.UnwrappedGift
|
||||
import rust.nostr.sdk.extractRelayList
|
||||
import rust.nostr.sdk.initLogger
|
||||
import rust.nostr.sdk.nip17ExtractRelayList
|
||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object NostrManager {
|
||||
val instance = Nostr()
|
||||
@@ -78,8 +80,6 @@ class Nostr {
|
||||
private set
|
||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||
private set
|
||||
var deviceSigner: AsyncNostrSigner? = null
|
||||
private set
|
||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||
private set
|
||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||
@@ -97,18 +97,20 @@ class Nostr {
|
||||
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||
|
||||
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
|
||||
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
|
||||
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||
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)
|
||||
}
|
||||
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||
_contactListUpdates.emit(contacts)
|
||||
}
|
||||
|
||||
suspend fun init(
|
||||
dbPath: String,
|
||||
@@ -190,6 +192,14 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun prune() {
|
||||
try {
|
||||
client?.database()?.wipe()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||
try {
|
||||
signer.switch(new)
|
||||
@@ -260,17 +270,39 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun handleNotifications(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||
onNewMessage: (UnsignedEvent) -> Unit,
|
||||
onSubscriptionClose: () -> Unit,
|
||||
) = supervisorScope {
|
||||
val now = Timestamp.now()
|
||||
val processedEvent = mutableSetOf<EventId>()
|
||||
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) {
|
||||
val notification = notifications.next() ?: continue
|
||||
@@ -287,58 +319,47 @@ class Nostr {
|
||||
if (processedEvent.contains(event.id())) continue
|
||||
processedEvent.add(event.id())
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
|
||||
try {
|
||||
val metadata = Metadata.fromJson(event.content())
|
||||
onMetadataUpdate(event.author(), metadata)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse metadata: $e")
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
||||
if (isSignedByUser(event = event)) {
|
||||
onContactListUpdate(event.tags().publicKeys())
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
||||
// Get all gift wrap events for the current user
|
||||
if (isSignedByUser(event = event)) {
|
||||
getUserMessages(msgRelayList = event)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
||||
try {
|
||||
val rumor = extractRumor(event)
|
||||
|
||||
// Logic to notify UI after processing
|
||||
// Cancel previous tracker if it exists
|
||||
eoseTrackerJob?.cancel()
|
||||
// Start a new tracker
|
||||
eoseTrackerJob = launch {
|
||||
delay(10000.milliseconds) // Wait for 10 seconds
|
||||
onSubscriptionClose()
|
||||
when (event.kind().asStd()) {
|
||||
KindStandard.METADATA -> {
|
||||
try {
|
||||
val metadata = Metadata.fromJson(event.content())
|
||||
onMetadataUpdate(event.author(), metadata)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse metadata: $e")
|
||||
}
|
||||
|
||||
// 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 -> {
|
||||
val subscriptionId = message.subscriptionId
|
||||
|
||||
if (subscriptionId == "gift-wraps") {
|
||||
onSubscriptionClose()
|
||||
if (message.subscriptionId == "gift-wraps") {
|
||||
eoseReceived = true
|
||||
if (giftWrapQueue.isEmpty) {
|
||||
_messageSyncState.update { it.copy(isSyncing = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,33 +387,50 @@ 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? {
|
||||
try {
|
||||
val filter = Filter().identifier(giftId.toHex())
|
||||
val event = client?.database()?.query(filter)?.first()
|
||||
|
||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||
try {
|
||||
// Construct the room id
|
||||
val roomId = rumor.roomId()
|
||||
|
||||
// Construct reference tags
|
||||
val tags = listOf(
|
||||
Tag.identifier(giftId.toHex()),
|
||||
Tag.publicKey(rumor.author()),
|
||||
Tag.event(rumor.id()!!),
|
||||
Tag.custom("r", listOf(roomId.toString())),
|
||||
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||
Tag.custom("k", listOf("14"))
|
||||
)
|
||||
|
||||
// Set event kind
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||
|
||||
// Construct event
|
||||
val event = EventBuilder(kind, rumor.asJson())
|
||||
@@ -400,36 +438,64 @@ class Nostr {
|
||||
.finalizeAsync(Keys.generate())
|
||||
|
||||
client?.database()?.saveEvent(event)
|
||||
} catch (e: Exception) {
|
||||
} catch (e: Throwable) {
|
||||
println("Failed to set cached rumor: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||
try {
|
||||
// Gift wrap must have at least one 'p' tag
|
||||
if (event.tags().publicKeys().isEmpty()) {
|
||||
println("No recipient tags found.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Event must be a gift wrap
|
||||
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
|
||||
println("Event is not a gift wrap.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if the rumor is already cached
|
||||
val cachedRumor = getCachedRumor(event.id())
|
||||
if (cachedRumor != null) return cachedRumor
|
||||
|
||||
// Unwrap the gift with current signer
|
||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
||||
val rumor = gift.rumor()
|
||||
// Decrypt the gift wrap event
|
||||
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||
val sealEvent = Event.fromJson(seal)
|
||||
|
||||
// Save the rumor to the database
|
||||
setCachedRumor(event.id(), rumor)
|
||||
// Verify seal event
|
||||
if (!sealEvent.verify()) {
|
||||
println("Failed to verify seal event.")
|
||||
return null
|
||||
}
|
||||
|
||||
// Return the rumor
|
||||
return rumor
|
||||
} catch (e: Exception) {
|
||||
println("Failed to unwrap gift: ${e.message}")
|
||||
// Decrypt the rumor
|
||||
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||
val unsignedEvent = UnsignedEvent.fromJson(rumor).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
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||
// 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.primal.net") to RelayMetadata.READ,
|
||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||
@@ -471,7 +537,7 @@ class Nostr {
|
||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||
// Send relay list event
|
||||
val relayList = getDefaultRelayList()
|
||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||
|
||||
client?.sendEvent(
|
||||
event = relayListEvent,
|
||||
@@ -546,7 +612,7 @@ class Nostr {
|
||||
|
||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||
return try {
|
||||
val kind = Kind.fromStd(KindStandard.METADATA);
|
||||
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||
|
||||
@@ -581,7 +647,7 @@ class Nostr {
|
||||
|
||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||
try {
|
||||
val limit = keys.size.toULong() * 4u;
|
||||
val limit = keys.size.toULong() * 4u
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
|
||||
// Construct a filter for metadata events
|
||||
@@ -615,7 +681,7 @@ class Nostr {
|
||||
ackPolicy = AckPolicy.none(),
|
||||
)
|
||||
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
|
||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
||||
val target = ReqTarget.auto(listOf(filter))
|
||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||
@@ -677,6 +743,21 @@ class Nostr {
|
||||
}
|
||||
}
|
||||
|
||||
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>? {
|
||||
try {
|
||||
val userPubkey =
|
||||
@@ -685,8 +766,8 @@ class Nostr {
|
||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||
|
||||
// Get all events sent by the user
|
||||
val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm"))
|
||||
// Get all DM events
|
||||
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||
val events = client?.database()?.query(filter)
|
||||
|
||||
// Collect rooms
|
||||
@@ -702,12 +783,13 @@ class Nostr {
|
||||
|
||||
// Check if the room already exists
|
||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
||||
val pubkeys = newRoom.members.toList()
|
||||
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
|
||||
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||
.customTag(rTag, newRoom.id.toString())
|
||||
|
||||
// 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
|
||||
roomsMap[newRoom.id] =
|
||||
@@ -731,7 +813,7 @@ class Nostr {
|
||||
// Merge the events
|
||||
return events
|
||||
?.toVec()
|
||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||
// Filter out events without public keys (receivers)
|
||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||
@@ -766,7 +848,7 @@ class Nostr {
|
||||
|
||||
suspend fun connectMsgRelays(event: Event) {
|
||||
try {
|
||||
val urls = nip17ExtractRelayList(event);
|
||||
val urls = nip17ExtractRelayList(event)
|
||||
for (url in urls) {
|
||||
client?.addRelay(url, RelayCapabilities.gossip())
|
||||
client?.connectRelay(url)
|
||||
@@ -910,4 +992,66 @@ class Nostr {
|
||||
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
|
||||
)
|
||||
|
||||
@@ -13,10 +13,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -35,6 +38,7 @@ import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
@@ -78,9 +82,14 @@ class NostrViewModel(
|
||||
val errorEvents = _errorEvents.receiveAsFlow()
|
||||
|
||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||
|
||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||
|
||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||
|
||||
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
init {
|
||||
// Skip the splash screen if a user is already logged in
|
||||
if (nostr.signer.currentUser != null) {
|
||||
@@ -107,7 +116,6 @@ class NostrViewModel(
|
||||
viewModelScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
coroutineScope {
|
||||
launch { refreshChatRooms() }
|
||||
launch { runObserver() }
|
||||
launch { runMetadataBatching() }
|
||||
}
|
||||
@@ -147,6 +155,21 @@ class NostrViewModel(
|
||||
}
|
||||
|
||||
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
|
||||
launch {
|
||||
nostr.newEvents.collect { event ->
|
||||
@@ -180,14 +203,6 @@ class NostrViewModel(
|
||||
updateMetadata(pubkey, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Observes subscription close
|
||||
launch {
|
||||
nostr.subscriptionClosed.collect {
|
||||
getChatRooms()
|
||||
_isPartialProcessedGiftWrap.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runMetadataBatching() = coroutineScope {
|
||||
@@ -324,12 +339,34 @@ class NostrViewModel(
|
||||
|
||||
fun logout() {
|
||||
viewModelScope.launch {
|
||||
secretStore.clear("user_signer")
|
||||
nostr.signer.switch(Keys.generate())
|
||||
_signerRequired.value = true
|
||||
try {
|
||||
_isBusy.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() {
|
||||
viewModelScope.launch {
|
||||
secretStore.set("notification_banner_dismissed", "true")
|
||||
@@ -616,6 +653,54 @@ class NostrViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||
@@ -772,6 +857,33 @@ class NostrViewModel(
|
||||
}
|
||||
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 {
|
||||
|
||||
70
zapstore.yaml
Normal file
70
zapstore.yaml
Normal 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. It’s 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
|
||||
Reference in New Issue
Block a user