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.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.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-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
@@ -69,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.9"
|
versionName = "0.2.1"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="remoteMessaging" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.expressiveLightColorScheme
|
import androidx.compose.material3.expressiveLightColorScheme
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -37,6 +35,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
|
|||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
import su.reya.coop.screens.MyQrScreen
|
import su.reya.coop.screens.MyQrScreen
|
||||||
@@ -45,6 +44,7 @@ import su.reya.coop.screens.NewIdentityScreen
|
|||||||
import su.reya.coop.screens.OnboardingScreen
|
import su.reya.coop.screens.OnboardingScreen
|
||||||
import su.reya.coop.screens.ProfileScreen
|
import su.reya.coop.screens.ProfileScreen
|
||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
import su.reya.coop.screens.UpdateProfileScreen
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
|
|
||||||
@@ -69,8 +69,6 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
fun App(viewModel: NostrViewModel) {
|
fun App(viewModel: NostrViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val navigator = remember(backStack) { Navigator(backStack) }
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
@@ -167,6 +165,9 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
entry<Screen.Home> {
|
entry<Screen.Home> {
|
||||||
HomeScreen()
|
HomeScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.RequestList> {
|
||||||
|
RequestListScreen()
|
||||||
|
}
|
||||||
entry<Screen.Onboarding> {
|
entry<Screen.Onboarding> {
|
||||||
OnboardingScreen()
|
OnboardingScreen()
|
||||||
}
|
}
|
||||||
@@ -177,7 +178,7 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
NewIdentityScreen()
|
NewIdentityScreen()
|
||||||
}
|
}
|
||||||
entry<Screen.Chat> { key ->
|
entry<Screen.Chat> { key ->
|
||||||
ChatScreen(id = key.id)
|
ChatScreen(id = key.id, screening = key.screening)
|
||||||
}
|
}
|
||||||
entry<Screen.NewChat> {
|
entry<Screen.NewChat> {
|
||||||
NewChatScreen()
|
NewChatScreen()
|
||||||
@@ -194,6 +195,9 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
entry<Screen.MyQr> {
|
entry<Screen.MyQr> {
|
||||||
MyQrScreen()
|
MyQrScreen()
|
||||||
}
|
}
|
||||||
|
entry<Screen.ContactList> {
|
||||||
|
ContactListScreen()
|
||||||
|
}
|
||||||
entry<Screen.Relay> {
|
entry<Screen.Relay> {
|
||||||
RelayScreen()
|
RelayScreen()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ sealed interface Screen : NavKey {
|
|||||||
|
|
||||||
return when (data.host) {
|
return when (data.host) {
|
||||||
// Matches coop://chat/{id}
|
// Matches coop://chat/{id}
|
||||||
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) }
|
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) }
|
||||||
// Matches coop://profile/{pubkey}
|
// Matches coop://profile/{pubkey}
|
||||||
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||||
else -> null
|
else -> null
|
||||||
@@ -24,11 +24,17 @@ sealed interface Screen : NavKey {
|
|||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Chat(val id: Long) : Screen
|
data object RequestList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Chat(val id: Long, val screening: Boolean = false) : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Profile(val pubkey: String) : Screen
|
data class Profile(val pubkey: String) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object ContactList : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object UpdateProfile : Screen
|
data object UpdateProfile : Screen
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val nostr by lazy { NostrManager.instance }
|
private val nostr by lazy { NostrManager.instance }
|
||||||
@@ -29,26 +31,26 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun isUserInApp(): Boolean {
|
|
||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
val notification = createNotification()
|
startAsForeground()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
|
||||||
} else {
|
|
||||||
startForeground(1, notification)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == "STOP_SERVICE") {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the nostr service in the foreground
|
||||||
|
startAsForeground()
|
||||||
|
|
||||||
|
// Check if the service is already running
|
||||||
if (notificationJob?.isActive == true) return START_STICKY
|
if (notificationJob?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
// Start the Nostr client
|
||||||
notificationJob = serviceScope.launch {
|
notificationJob = serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
Log.d("Coop", "Starting Nostr in background")
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
@@ -63,8 +65,10 @@ class NostrForegroundService : Service() {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
nostr.connectBootstrapRelays()
|
nostr.connectBootstrapRelays()
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
nostr.handleNotifications(
|
nostr.handleNotifications(
|
||||||
onMetadataUpdate = { pubkey, metadata ->
|
onMetadataUpdate = { pubkey, metadata ->
|
||||||
@@ -73,9 +77,6 @@ class NostrForegroundService : Service() {
|
|||||||
onContactListUpdate = { contacts ->
|
onContactListUpdate = { contacts ->
|
||||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
||||||
},
|
},
|
||||||
onSubscriptionClose = {
|
|
||||||
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
|
||||||
},
|
|
||||||
onNewMessage = { event ->
|
onNewMessage = { event ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
if (!isUserInApp()) {
|
if (!isUserInApp()) {
|
||||||
@@ -93,6 +94,26 @@ class NostrForegroundService : Service() {
|
|||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isUserInApp(): Boolean {
|
||||||
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAsForeground() {
|
||||||
|
val notification = createNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStopServicePendingIntent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NostrForegroundService::class.java).apply {
|
||||||
|
action = "STOP_SERVICE"
|
||||||
|
}
|
||||||
|
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
@@ -115,10 +136,12 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
private fun createNotification(content: String? = null): Notification {
|
private fun createNotification(content: String? = null): Notification {
|
||||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||||
|
.setGroup(GROUP_KEY_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setCategory(Notification.CATEGORY_SERVICE)
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
builder.setContentTitle("Coop")
|
builder.setContentTitle("Coop")
|
||||||
|
|||||||
@@ -4,15 +4,11 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
@@ -22,11 +18,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -36,39 +36,50 @@ import androidx.compose.material3.TextField
|
|||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_cancel
|
||||||
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.formatAsGroupHeader
|
||||||
|
import su.reya.coop.humanReadable
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.shared.nameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(id: Long) {
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
@@ -92,12 +103,13 @@ fun ChatScreen(id: Long) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room!!.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
var requireScreening by remember { mutableStateOf(screening) }
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
@@ -155,9 +167,7 @@ fun ChatScreen(id: Long) {
|
|||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
LoadingIndicator(
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = picture,
|
||||||
@@ -208,7 +218,12 @@ fun ChatScreen(id: Long) {
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
if (messages.isNotEmpty()) {
|
if (requireScreening) {
|
||||||
|
room?.let { ScreenerCard(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
when (messages.isNotEmpty()) {
|
||||||
|
true -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -219,16 +234,19 @@ fun ChatScreen(id: Long) {
|
|||||||
) {
|
) {
|
||||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||||
items(
|
items(
|
||||||
messagesInGroup,
|
items = messagesInGroup,
|
||||||
key = { it.id()?.toBech32()!! }) { event ->
|
key = { it.id()?.toBech32() ?: it.hashCode() }
|
||||||
ChatMessage(event)
|
) {
|
||||||
|
ChatMessage(it)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
DateSeparator(dateHeader)
|
DateSeparator(dateHeader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
false -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -254,6 +272,38 @@ fun ChatScreen(id: Long) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
ChatInput(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
@@ -265,9 +315,135 @@ fun ChatScreen(id: Long) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScreenerCard(room: Room) {
|
||||||
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isContact by remember { mutableStateOf(false) }
|
||||||
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
|
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||||
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
|
val picture = profile?.picture
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) {
|
||||||
|
scope.launch {
|
||||||
|
// Check contact
|
||||||
|
viewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
|
// Get mutual contacts
|
||||||
|
viewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
|
// Get the last activity
|
||||||
|
viewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 48.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
picture = picture,
|
||||||
|
description = "Profile picture",
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pubkey.short(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isContact) "Contact" else "Not a contact",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check_circle),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DateSeparator(date: String) {
|
fun DateSeparator(date: String) {
|
||||||
Box(
|
Box(
|
||||||
@@ -345,12 +521,9 @@ fun ChatInput(
|
|||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit
|
onSend: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
.height(IntrinsicSize.Min),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
@@ -367,9 +540,7 @@ fun ChatInput(
|
|||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(56.dp),
|
||||||
.fillMaxHeight()
|
|
||||||
.aspectRatio(1f),
|
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
@@ -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.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
@@ -84,7 +85,9 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.ic_close
|
import coop.composeapp.generated.resources.ic_close
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
@@ -93,11 +96,12 @@ import su.reya.coop.LocalNostrViewModel
|
|||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.ago
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.shared.nameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@@ -111,20 +115,22 @@ fun HomeScreen() {
|
|||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
|
||||||
|
|
||||||
val userProfile by currentUserProfile.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 scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(true)
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
val currentUser = viewModel.currentUser() ?: return
|
||||||
|
val currentUserProfile = viewModel.getMetadata(currentUser)
|
||||||
|
|
||||||
|
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
|
val 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 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
@@ -140,6 +146,11 @@ fun HomeScreen() {
|
|||||||
// State will be updated by LifecycleResumeEffect
|
// State will be updated by LifecycleResumeEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Partition chat rooms into requests and ongoing
|
||||||
|
val (requests, ongoing) = remember(chatRooms) {
|
||||||
|
chatRooms.partition { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
LifecycleResumeEffect(context) {
|
LifecycleResumeEffect(context) {
|
||||||
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
@@ -175,10 +186,18 @@ fun HomeScreen() {
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
title = {
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Coop",
|
text = "Coop",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
)
|
)
|
||||||
|
if (isSyncing) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// QR Scanner
|
// QR Scanner
|
||||||
@@ -350,7 +369,11 @@ fun HomeScreen() {
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
if (requests.isNotEmpty()) {
|
||||||
|
item { NewRequests(requests) }
|
||||||
|
}
|
||||||
|
|
||||||
|
items(ongoing, key = { it.id }) { room ->
|
||||||
ChatRoom(
|
ChatRoom(
|
||||||
room = room,
|
room = room,
|
||||||
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
@@ -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)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room.nameFlow(viewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
@@ -636,7 +742,9 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
if (!room.lastMessage.isNullOrBlank()) {
|
if (!room.lastMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = room.lastMessage!!,
|
text = room.lastMessage!!,
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -656,8 +764,7 @@ fun BottomMenuList(
|
|||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Contact List" to { },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Spams & Blocks" to { },
|
|
||||||
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { }
|
"Settings" to { }
|
||||||
)
|
)
|
||||||
@@ -683,7 +790,7 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.logout() },
|
onClick = { onDismiss { viewModel.logout() } },
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -94,17 +94,18 @@ fun ImportScreen() {
|
|||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching {
|
runCatching {
|
||||||
if (result.startsWith("nsec")) {
|
if (result.startsWith("nsec1")) {
|
||||||
Keys.parse(result)
|
Keys.parse(result)
|
||||||
} else if (result.startsWith("bunker://")) {
|
} else if (result.startsWith("bunker://")) {
|
||||||
NostrConnectUri.parse(result)
|
NostrConnectUri.parse(result)
|
||||||
} else {
|
} else {
|
||||||
throw IllegalArgumentException("Invalid secret: $result")
|
throw IllegalArgumentException("Unsupported secret format")
|
||||||
}
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
secret = result
|
||||||
|
}.onFailure { e ->
|
||||||
|
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
|
||||||
}
|
}
|
||||||
.onSuccess { it -> secret = result }
|
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -78,7 +79,7 @@ fun NewChatScreen() {
|
|||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
delay(500) // 500ms debounce
|
delay(500.milliseconds)
|
||||||
|
|
||||||
if (query.startsWith("npub1")) {
|
if (query.startsWith("npub1")) {
|
||||||
val pubkey = try {
|
val pubkey = try {
|
||||||
@@ -108,14 +109,13 @@ fun NewChatScreen() {
|
|||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
// Verify the content
|
// Verify the content
|
||||||
runCatching { PublicKey.parse(result) }
|
runCatching {
|
||||||
.onSuccess { pubkey ->
|
PublicKey.parse(result)
|
||||||
|
}.onSuccess { pubkey ->
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
|
}.onFailure { e ->
|
||||||
|
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
|
||||||
println("Failed to parse QR: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@@ -37,6 +36,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_chat
|
import coop.composeapp.generated.resources.ic_chat
|
||||||
@@ -55,26 +55,27 @@ import su.reya.coop.short
|
|||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(pubkey: String) {
|
fun ProfileScreen(pubkey: String) {
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
listOf(
|
listOf(
|
||||||
"Username:" to (profile?.name ?: "None"),
|
"Username:" to (profile?.name ?: "None"),
|
||||||
"Website:" to (profile?.website ?: "None"),
|
"Website:" to (profile?.website ?: "None"),
|
||||||
"Lightning Address:" to (profile?.lud16 ?: "None"),
|
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { },
|
title = { /* empty */ },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -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.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -44,10 +43,10 @@ fun ScanScreen() {
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "Scan QR",
|
text = "Scan a Nostr Address",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -97,14 +96,6 @@ fun ScanScreen() {
|
|||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = "Scan a Nostr address",
|
|
||||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 64.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,7 @@ import su.reya.coop.NostrViewModel
|
|||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
fun Room.nameFlow(viewModel: NostrViewModel): Flow<String> {
|
||||||
// Return early if there's a custom subject/room name
|
// Return early if there's a custom subject/room name
|
||||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
|||||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
||||||
val names = metadataArray.mapIndexed { i, metadata ->
|
val names = metadataArray.mapIndexed { i, metadata ->
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
profile?.name?.takeIf { it.isNotBlank() }
|
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: profile?.displayName?.takeIf { it.isNotBlank() }
|
?: profile?.name?.takeIf { it.isNotBlank() }
|
||||||
?: displayMembers[i].short()
|
?: displayMembers[i].short()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ kotlin {
|
|||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.7")
|
implementation("su.reya:nostr-sdk-kmp:0.3.1")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.16.2")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import io.ktor.client.HttpClient
|
|||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.supervisorScope
|
import kotlinx.coroutines.supervisorScope
|
||||||
import rust.nostr.sdk.AckPolicy
|
import rust.nostr.sdk.AckPolicy
|
||||||
@@ -50,13 +53,12 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions
|
|||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
|
||||||
import rust.nostr.sdk.extractRelayList
|
import rust.nostr.sdk.extractRelayList
|
||||||
import rust.nostr.sdk.initLogger
|
import rust.nostr.sdk.initLogger
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
object NostrManager {
|
object NostrManager {
|
||||||
val instance = Nostr()
|
val instance = Nostr()
|
||||||
@@ -78,8 +80,6 @@ class Nostr {
|
|||||||
private set
|
private set
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
private set
|
private set
|
||||||
var deviceSigner: AsyncNostrSigner? = null
|
|
||||||
private set
|
|
||||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
private set
|
private set
|
||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
@@ -97,18 +97,20 @@ class Nostr {
|
|||||||
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event)
|
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit)
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
|
|
||||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
|
|
||||||
_metadataUpdates.emit(pubkey to metadata)
|
_metadataUpdates.emit(pubkey to metadata)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||||
_contactListUpdates.emit(contacts)
|
_contactListUpdates.emit(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun init(
|
suspend fun init(
|
||||||
dbPath: String,
|
dbPath: String,
|
||||||
@@ -190,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) {
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
try {
|
try {
|
||||||
signer.switch(new)
|
signer.switch(new)
|
||||||
@@ -260,17 +270,39 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
suspend fun handleNotifications(
|
suspend fun handleNotifications(
|
||||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||||
onNewMessage: (UnsignedEvent) -> Unit,
|
onNewMessage: (UnsignedEvent) -> Unit,
|
||||||
onSubscriptionClose: () -> Unit,
|
|
||||||
) = supervisorScope {
|
) = supervisorScope {
|
||||||
val now = Timestamp.now()
|
val now = Timestamp.now()
|
||||||
val processedEvent = mutableSetOf<EventId>()
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
val notifications = client?.notifications() ?: return@supervisorScope
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
var eoseTrackerJob: Job? = null
|
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||||
|
var processedCount = 0
|
||||||
|
var eoseReceived = false
|
||||||
|
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
for (event in giftWrapQueue) {
|
||||||
|
val rumor = extractRumor(event) ?: continue
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
// Trigger new message notification
|
||||||
|
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||||
|
onNewMessage(rumor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
_messageSyncState.update {
|
||||||
|
it.copy(
|
||||||
|
processedCount = processedCount,
|
||||||
|
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val notification = notifications.next() ?: continue
|
val notification = notifications.next() ?: continue
|
||||||
@@ -287,7 +319,8 @@ class Nostr {
|
|||||||
if (processedEvent.contains(event.id())) continue
|
if (processedEvent.contains(event.id())) continue
|
||||||
processedEvent.add(event.id())
|
processedEvent.add(event.id())
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
|
when (event.kind().asStd()) {
|
||||||
|
KindStandard.METADATA -> {
|
||||||
try {
|
try {
|
||||||
val metadata = Metadata.fromJson(event.content())
|
val metadata = Metadata.fromJson(event.content())
|
||||||
onMetadataUpdate(event.author(), metadata)
|
onMetadataUpdate(event.author(), metadata)
|
||||||
@@ -296,49 +329,37 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
KindStandard.CONTACT_LIST -> {
|
||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
onContactListUpdate(event.tags().publicKeys())
|
val pubkeys = event.tags().publicKeys()
|
||||||
|
// Get mutual contacts
|
||||||
|
getMutualContacts(pubkeys)
|
||||||
|
// Emit contact list update
|
||||||
|
onContactListUpdate(pubkeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
KindStandard.INBOX_RELAYS -> {
|
||||||
// Get all gift wrap events for the current user
|
// Get all gift wrap events for the current user
|
||||||
if (isSignedByUser(event = event)) {
|
if (isSignedByUser(event = event)) {
|
||||||
getUserMessages(msgRelayList = event)
|
getUserMessages(msgRelayList = event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
KindStandard.GIFT_WRAP -> {
|
||||||
try {
|
giftWrapQueue.send(event)
|
||||||
val rumor = extractRumor(event)
|
|
||||||
|
|
||||||
// Logic to notify UI after processing
|
|
||||||
// Cancel previous tracker if it exists
|
|
||||||
eoseTrackerJob?.cancel()
|
|
||||||
// Start a new tracker
|
|
||||||
eoseTrackerJob = launch {
|
|
||||||
delay(10000.milliseconds) // Wait for 10 seconds
|
|
||||||
onSubscriptionClose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new message
|
else -> {}
|
||||||
rumor?.createdAt()?.asSecs()?.let {
|
|
||||||
if (it >= now.asSecs()) {
|
|
||||||
onNewMessage(rumor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to extract rumor: $e")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
val subscriptionId = message.subscriptionId
|
if (message.subscriptionId == "gift-wraps") {
|
||||||
|
eoseReceived = true
|
||||||
if (subscriptionId == "gift-wraps") {
|
if (giftWrapQueue.isEmpty) {
|
||||||
onSubscriptionClose()
|
_messageSyncState.update { it.copy(isSyncing = false) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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? {
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
try {
|
try {
|
||||||
val filter = Filter().identifier(giftId.toHex())
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
val event = client?.database()?.query(filter)?.first()
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
try {
|
try {
|
||||||
// Construct the room id
|
|
||||||
val roomId = rumor.roomId()
|
|
||||||
|
|
||||||
// Construct reference tags
|
// Construct reference tags
|
||||||
val tags = listOf(
|
val tags = listOf(
|
||||||
Tag.identifier(giftId.toHex()),
|
Tag.identifier(giftId.toHex()),
|
||||||
Tag.publicKey(rumor.author()),
|
Tag.publicKey(rumor.author()),
|
||||||
Tag.event(rumor.id()!!),
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
Tag.custom("r", listOf(roomId.toString())),
|
|
||||||
Tag.custom("k", listOf("14"))
|
Tag.custom("k", listOf("14"))
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set event kind
|
// Set event kind
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
// Construct event
|
// Construct event
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
@@ -400,36 +438,64 @@ class Nostr {
|
|||||||
.finalizeAsync(Keys.generate())
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
client?.database()?.saveEvent(event)
|
||||||
} catch (e: Exception) {
|
} catch (e: Throwable) {
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
try {
|
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
|
// Check if the rumor is already cached
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
if (cachedRumor != null) return cachedRumor
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
// Unwrap the gift with current signer
|
// Decrypt the gift wrap event
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
val rumor = gift.rumor()
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
// Save the rumor to the database
|
// Verify seal event
|
||||||
setCachedRumor(event.id(), rumor)
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
// Return the rumor
|
return null
|
||||||
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the rumor for later use
|
||||||
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
|
return unsignedEvent
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
// Construct a list of relays
|
// Construct a list of relays
|
||||||
val relayList = mapOf<RelayUrl, RelayMetadata>(
|
val relayList = mapOf(
|
||||||
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
@@ -471,7 +537,7 @@ class Nostr {
|
|||||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
// Send relay list event
|
// Send relay list event
|
||||||
val relayList = getDefaultRelayList()
|
val relayList = getDefaultRelayList()
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
client?.sendEvent(
|
client?.sendEvent(
|
||||||
event = relayListEvent,
|
event = relayListEvent,
|
||||||
@@ -546,7 +612,7 @@ class Nostr {
|
|||||||
|
|
||||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
return try {
|
return try {
|
||||||
val kind = Kind.fromStd(KindStandard.METADATA);
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
@@ -581,7 +647,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
try {
|
try {
|
||||||
val limit = keys.size.toULong() * 4u;
|
val limit = keys.size.toULong() * 4u
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
// Construct a filter for metadata events
|
// Construct a filter for metadata events
|
||||||
@@ -615,7 +681,7 @@ class Nostr {
|
|||||||
ackPolicy = AckPolicy.none(),
|
ackPolicy = AckPolicy.none(),
|
||||||
)
|
)
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
||||||
val target = ReqTarget.auto(listOf(filter))
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
@@ -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>? {
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
try {
|
try {
|
||||||
val userPubkey =
|
val userPubkey =
|
||||||
@@ -685,8 +766,8 @@ class Nostr {
|
|||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
// Get all events sent by the user
|
// Get all DM events
|
||||||
val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm"))
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
val events = client?.database()?.query(filter)
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
// Collect rooms
|
// Collect rooms
|
||||||
@@ -702,12 +783,13 @@ class Nostr {
|
|||||||
|
|
||||||
// Check if the room already exists
|
// Check if the room already exists
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||||
val pubkeys = newRoom.members.toList()
|
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||||
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
|
.customTag(rTag, newRoom.id.toString())
|
||||||
|
|
||||||
// Determine if it's an ongoing room
|
// Determine if it's an ongoing room
|
||||||
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
|
val isOngoing =
|
||||||
|
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||||
|
|
||||||
// Append room to map
|
// Append room to map
|
||||||
roomsMap[newRoom.id] =
|
roomsMap[newRoom.id] =
|
||||||
@@ -731,7 +813,7 @@ class Nostr {
|
|||||||
// Merge the events
|
// Merge the events
|
||||||
return events
|
return events
|
||||||
?.toVec()
|
?.toVec()
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||||
// Filter out events without public keys (receivers)
|
// Filter out events without public keys (receivers)
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
@@ -766,7 +848,7 @@ class Nostr {
|
|||||||
|
|
||||||
suspend fun connectMsgRelays(event: Event) {
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
try {
|
try {
|
||||||
val urls = nip17ExtractRelayList(event);
|
val urls = nip17ExtractRelayList(event)
|
||||||
for (url in urls) {
|
for (url in urls) {
|
||||||
client?.addRelay(url, RelayCapabilities.gossip())
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
client?.connectRelay(url)
|
client?.connectRelay(url)
|
||||||
@@ -910,4 +992,66 @@ class Nostr {
|
|||||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().author(pubkey).limit(3u)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val events = client?.fetchEvents(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
timeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
return events?.first()?.createdAt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||||
|
|
||||||
|
return pubkeys.contains(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val contacts = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
|
||||||
|
contacts.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MessageSyncState(
|
||||||
|
val processedCount: Int = 0,
|
||||||
|
val isSyncing: Boolean = false
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -35,6 +38,7 @@ import rust.nostr.sdk.PublicKey
|
|||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import rust.nostr.sdk.Tag
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
import su.reya.coop.storage.SecretStorage
|
import su.reya.coop.storage.SecretStorage
|
||||||
@@ -78,9 +82,14 @@ class NostrViewModel(
|
|||||||
val errorEvents = _errorEvents.receiveAsFlow()
|
val errorEvents = _errorEvents.receiveAsFlow()
|
||||||
|
|
||||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||||
|
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
|
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
val isSyncing = nostr.messageSyncState.map { it.isSyncing }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Skip the splash screen if a user is already logged in
|
// Skip the splash screen if a user is already logged in
|
||||||
if (nostr.signer.currentUser != null) {
|
if (nostr.signer.currentUser != null) {
|
||||||
@@ -107,7 +116,6 @@ class NostrViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
launch { refreshChatRooms() }
|
|
||||||
launch { runObserver() }
|
launch { runObserver() }
|
||||||
launch { runMetadataBatching() }
|
launch { runMetadataBatching() }
|
||||||
}
|
}
|
||||||
@@ -147,6 +155,21 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runObserver() = coroutineScope {
|
private suspend fun runObserver() = coroutineScope {
|
||||||
|
// Observe message sync progress
|
||||||
|
launch {
|
||||||
|
nostr.messageSyncState.collect { state ->
|
||||||
|
// When at least some messages are processed, allow UI to show the list
|
||||||
|
if (state.processedCount > 0) {
|
||||||
|
_isPartialProcessedGiftWrap.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI every 10 messages OR when sync is fully done
|
||||||
|
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Observe new messages
|
// Observe new messages
|
||||||
launch {
|
launch {
|
||||||
nostr.newEvents.collect { event ->
|
nostr.newEvents.collect { event ->
|
||||||
@@ -180,14 +203,6 @@ class NostrViewModel(
|
|||||||
updateMetadata(pubkey, metadata)
|
updateMetadata(pubkey, metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observes subscription close
|
|
||||||
launch {
|
|
||||||
nostr.subscriptionClosed.collect {
|
|
||||||
getChatRooms()
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runMetadataBatching() = coroutineScope {
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
@@ -324,11 +339,33 @@ class NostrViewModel(
|
|||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
secretStore.clear("user_signer")
|
try {
|
||||||
|
_isBusy.value = true
|
||||||
|
// Reset the nostr signer and prune the database
|
||||||
nostr.signer.switch(Keys.generate())
|
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
|
_signerRequired.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetInternalState() {
|
||||||
|
_chatRooms.value = emptySet()
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
_isPartialProcessedGiftWrap.value = false
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
_isNotificationBannerDismissed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
fun dismissNotificationBanner() {
|
fun dismissNotificationBanner() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -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 {
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
try {
|
try {
|
||||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||||
@@ -772,6 +857,33 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
return try {
|
||||||
|
nostr.verifyActivity(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
return try {
|
||||||
|
nostr.verifyContact(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
return try {
|
||||||
|
nostr.mutualContacts(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
setOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun PublicKey.short(): String {
|
fun PublicKey.short(): String {
|
||||||
|
|||||||
70
zapstore.yaml
Normal file
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