Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c6426d27 | |||
| 801347ccb1 | |||
| 19daea119d | |||
| 8b2f0faa59 | |||
| a2d28ecc40 | |||
| cd5a393a01 | |||
| 3bfe2308ba | |||
| eed9d401da | |||
| 3943e2baab | |||
| d5ea5570b6 | |||
| 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,9 +24,9 @@ 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.2")
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||||
}
|
}
|
||||||
@@ -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.3"
|
||||||
}
|
}
|
||||||
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>
|
||||||
@@ -36,7 +36,8 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.App.Starting">
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="remoteMessaging" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M440,680L520,680L520,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,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="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
|
||||||
|
</vector>
|
||||||
@@ -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>
|
||||||
@@ -6,6 +6,8 @@ import android.content.Intent
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerResult
|
||||||
|
|
||||||
class AndroidExternalSigner(
|
class AndroidExternalSigner(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -36,7 +34,9 @@ import androidx.navigation3.runtime.entryProvider
|
|||||||
import androidx.navigation3.runtime.rememberNavBackStack
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.repository.ErrorRepository
|
||||||
|
import su.reya.coop.screens.chat.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,13 +45,25 @@ 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
|
||||||
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
|
|
||||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||||
error("No NostrViewModel provided")
|
error("No NostrViewModel provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
||||||
|
error("No ChatViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
|
||||||
|
error("No AuthViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
@@ -66,16 +78,20 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(viewModel: NostrViewModel) {
|
fun App(
|
||||||
|
nostrViewModel: NostrViewModel,
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val activity = context as? ComponentActivity
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val sheetState = rememberModalBottomSheetState()
|
|
||||||
val backStack = rememberNavBackStack(Screen.Home)
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val navigator = remember(backStack) { Navigator(backStack) }
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
val qrScanResult = remember { QrScanResult() }
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
|
// Get the signer required state
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val signerRequired = authState.signerRequired
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -102,7 +118,7 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.errorEvents.collect { message ->
|
ErrorRepository.errors.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,11 +161,14 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNostrViewModel provides viewModel,
|
LocalNostrViewModel provides nostrViewModel,
|
||||||
|
LocalChatViewModel provides chatViewModel,
|
||||||
|
LocalAuthViewModel provides authViewModel,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavigator provides navigator,
|
LocalNavigator provides navigator,
|
||||||
LocalScanResult provides qrScanResult,
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
onBack = {
|
onBack = {
|
||||||
@@ -167,6 +186,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 +199,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 +216,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -21,16 +24,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
val externalSignerLauncher = ExternalSignerLauncher()
|
val externalSignerLauncher = ExternalSignerLauncher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val viewModel: NostrViewModel by viewModels {
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
|
private val androidSigner =
|
||||||
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
|
private val secretStore = SecretStore(this@MainActivity)
|
||||||
|
private val nostrViewModel =
|
||||||
|
NostrViewModel(NostrManager.instance)
|
||||||
|
private val chatViewModel =
|
||||||
|
ChatViewModel(NostrManager.instance)
|
||||||
|
private val authViewModel =
|
||||||
|
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
||||||
|
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
val secretStore = SecretStore(this@MainActivity)
|
return when {
|
||||||
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
|
||||||
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
||||||
|
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
|
||||||
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
} as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
||||||
|
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||||
|
private val authViewModel: AuthViewModel by viewModels { factory }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
throwable.printStackTrace()
|
throwable.printStackTrace()
|
||||||
@@ -68,17 +88,19 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
viewModel.signerRequired.value == null
|
authViewModel.state.value.signerRequired == null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
|
|
||||||
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(viewModel = viewModel)
|
App(
|
||||||
|
nostrViewModel = nostrViewModel,
|
||||||
|
chatViewModel = chatViewModel,
|
||||||
|
authViewModel = authViewModel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
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 +32,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,18 +66,17 @@ 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 ->
|
||||||
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
|
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
|
||||||
},
|
},
|
||||||
onContactListUpdate = { contacts ->
|
onContactListUpdate = { contacts ->
|
||||||
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
|
||||||
},
|
|
||||||
onSubscriptionClose = {
|
|
||||||
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
|
||||||
},
|
},
|
||||||
onNewMessage = { event ->
|
onNewMessage = { event ->
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
@@ -93,6 +95,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 +137,12 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
private fun createNotification(content: String? = null): Notification {
|
private fun createNotification(content: String? = null): Notification {
|
||||||
val builder = NotificationCompat.Builder(this, "nostr_service")
|
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||||
|
.setGroup(GROUP_KEY_MESSAGES)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setCategory(Notification.CATEGORY_SERVICE)
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||||
|
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
builder.setContentTitle("Coop")
|
builder.setContentTitle("Coop")
|
||||||
|
|||||||
@@ -1,385 +0,0 @@
|
|||||||
package su.reya.coop.screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
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.Badge
|
|
||||||
import androidx.compose.material3.BadgedBox
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.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.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import coop.composeapp.generated.resources.ic_send
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.LocalNavigator
|
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.Screen
|
|
||||||
import su.reya.coop.formatAsGroupHeader
|
|
||||||
import su.reya.coop.roomId
|
|
||||||
import su.reya.coop.shared.Avatar
|
|
||||||
import su.reya.coop.shared.displayNameFlow
|
|
||||||
import su.reya.coop.shared.pictureFlow
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatScreen(id: Long) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val viewModel = LocalNostrViewModel.current
|
|
||||||
|
|
||||||
// Get chat room by ID
|
|
||||||
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
|
|
||||||
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
|
||||||
|
|
||||||
// Show empty screen
|
|
||||||
if (room == null) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Something went wrong.",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
|
||||||
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
|
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
|
||||||
|
|
||||||
val groupedMessages = remember(messages.toList()) {
|
|
||||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(id) {
|
|
||||||
// Start loading spinner
|
|
||||||
loading = true
|
|
||||||
|
|
||||||
// Get messages
|
|
||||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
|
||||||
messages.clear()
|
|
||||||
messages.addAll(initialMessages)
|
|
||||||
|
|
||||||
// Stop loading spinner
|
|
||||||
loading = false
|
|
||||||
|
|
||||||
// Get msg relays for each member
|
|
||||||
viewModel.chatRoomConnect(id)
|
|
||||||
|
|
||||||
// Handle new messages
|
|
||||||
viewModel.newEvents.collect { event ->
|
|
||||||
if (event.roomId() == id) {
|
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
|
||||||
messages.add(0, event)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If the event is not in the current room, it's a new message from another user
|
|
||||||
newOtherMessages++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
|
||||||
if (messages.isNotEmpty()) {
|
|
||||||
listState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
room!!.members.firstOrNull()?.let { pubkey ->
|
|
||||||
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
if (loading) {
|
|
||||||
LoadingIndicator(
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Avatar(
|
|
||||||
picture = picture,
|
|
||||||
description = displayName,
|
|
||||||
size = 32.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
BadgedBox(
|
|
||||||
badge = {
|
|
||||||
if (newOtherMessages > 0) {
|
|
||||||
Badge {
|
|
||||||
Text(newOtherMessages.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
IconButton(onClick = { navigator.goBack() }) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
||||||
) {
|
|
||||||
if (messages.isNotEmpty()) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
reverseLayout = true,
|
|
||||||
state = listState,
|
|
||||||
) {
|
|
||||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
|
||||||
items(
|
|
||||||
messagesInGroup,
|
|
||||||
key = { it.id()?.toBech32()!! }) { event ->
|
|
||||||
ChatMessage(event)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DateSeparator(dateHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No messages yet",
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Your conversations will appear here.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChatInput(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
onSend = {
|
|
||||||
viewModel.sendMessage(id, text)
|
|
||||||
text = ""
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DateSeparator(date: String) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = date,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatMessage(
|
|
||||||
rumor: UnsignedEvent
|
|
||||||
) {
|
|
||||||
val viewModel = LocalNostrViewModel.current
|
|
||||||
val currentUser = viewModel.currentUser()
|
|
||||||
val isMine = rumor.author() == currentUser
|
|
||||||
|
|
||||||
val bubbleShape = if (isMine) {
|
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
|
||||||
} else {
|
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
|
||||||
}
|
|
||||||
|
|
||||||
val containerColor =
|
|
||||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
|
||||||
|
|
||||||
val contentColor =
|
|
||||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
shape = bubbleShape,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 280.dp)
|
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
val id = rumor.id()
|
|
||||||
if (id != null) {
|
|
||||||
val sent = viewModel.isMessageSent(id)
|
|
||||||
println("Sent: $sent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = rumor.content(),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatInput(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
onSend: () -> Unit
|
|
||||||
) {
|
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
.height(IntrinsicSize.Min),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
placeholder = { Text("Message") },
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.aspectRatio(1f),
|
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_send),
|
|
||||||
contentDescription = "Send"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.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.TextButton
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.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.LocalChatViewModel
|
||||||
|
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 nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
if (contactList.isNotEmpty()) {
|
||||||
|
val contacts = remember(contactList) { contactList.toList() }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
|
) {
|
||||||
|
itemsIndexed(contacts) { index, pubkey ->
|
||||||
|
ContactListItem(
|
||||||
|
pubkey = pubkey,
|
||||||
|
index = index,
|
||||||
|
total = contacts.size,
|
||||||
|
onClick = {
|
||||||
|
val room = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
|
navigator.navigate(Screen.Chat(room))
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
contactToDelete = pubkey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactToDelete != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { contactToDelete = null },
|
||||||
|
title = { Text("Delete Contact") },
|
||||||
|
text = { Text("Are you sure you want to remove this contact?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
contactToDelete?.let { nostrViewModel.removeContact(it) }
|
||||||
|
contactToDelete = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { contactToDelete = null }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val nostrViewModel = 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 = nostrViewModel.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,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
|
index = index,
|
||||||
|
count = total
|
||||||
|
),
|
||||||
|
leadingContent = {
|
||||||
|
Avatar(
|
||||||
|
picture = profile?.picture,
|
||||||
|
description = profile?.name,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(pubkey.short()) },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = profile?.name ?: "No name",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState
|
|||||||
import androidx.compose.material3.toShape
|
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.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -76,6 +75,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,22 +84,24 @@ 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.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
|
||||||
|
import su.reya.coop.LocalAuthViewModel
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
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.rememberUiState
|
||||||
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.pictureFlow
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -109,22 +111,25 @@ fun HomeScreen() {
|
|||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val authViewModel = LocalAuthViewModel.current
|
||||||
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 userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBannerDismissed = authState.isNotificationBannerDismissed
|
||||||
|
|
||||||
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,13 +145,18 @@ 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 { }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.getChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
@@ -154,7 +164,7 @@ fun HomeScreen() {
|
|||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey ->
|
.onSuccess { pubkey ->
|
||||||
try {
|
try {
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
@@ -175,10 +185,18 @@ fun HomeScreen() {
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Row(
|
||||||
text = "Coop",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
)
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Coop",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
if (isSyncing) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// QR Scanner
|
// QR Scanner
|
||||||
@@ -191,8 +209,8 @@ fun HomeScreen() {
|
|||||||
// User
|
// User
|
||||||
IconButton(onClick = { showBottomSheet = true }) {
|
IconButton(onClick = { showBottomSheet = true }) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
size = 32.dp,
|
size = 32.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -262,7 +280,7 @@ fun HomeScreen() {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { viewModel.dismissNotificationBanner() },
|
onClick = { authViewModel.dismissNotificationBanner() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(text = "Maybe later")
|
Text(text = "Maybe later")
|
||||||
@@ -303,7 +321,7 @@ fun HomeScreen() {
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
viewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -350,7 +368,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)) }
|
||||||
@@ -369,14 +391,6 @@ fun HomeScreen() {
|
|||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val pubkey = viewModel.currentUser()
|
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
|
||||||
|
|
||||||
val userName =
|
|
||||||
userProfile?.asRecord()?.displayName
|
|
||||||
?: userProfile?.asRecord()?.name
|
|
||||||
?: "No name"
|
|
||||||
|
|
||||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
@@ -401,8 +415,8 @@ fun HomeScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
@@ -412,7 +426,7 @@ fun HomeScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = userName,
|
text = userProfile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -423,16 +437,15 @@ fun HomeScreen() {
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pubkey?.let {
|
userProfile?.publicKey?.let {
|
||||||
val bech32 = it.toBech32()
|
val bech32 = it.toBech32()
|
||||||
val data =
|
val data = ClipData.newPlainText(bech32, bech32)
|
||||||
ClipData.newPlainText(bech32, bech32)
|
|
||||||
clipboardManager.setClipEntry(ClipEntry(data))
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = shortPubkey)
|
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||||
}
|
}
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -456,7 +469,7 @@ fun HomeScreen() {
|
|||||||
// Show the relay setup dialog if the msg relay list is empty
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
if (isRelayListEmpty) {
|
if (isRelayListEmpty) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
) {
|
) {
|
||||||
@@ -563,7 +576,7 @@ fun HomeScreen() {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
isBusy = true
|
isBusy = true
|
||||||
try {
|
try {
|
||||||
viewModel.refetchMsgRelays(currentUser)
|
nostrViewModel.refetchMsgRelays()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -583,7 +596,7 @@ fun HomeScreen() {
|
|||||||
enabled = !isBusy,
|
enabled = !isBusy,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.useDefaultMsgRelayList()
|
nostrViewModel.useDefaultMsgRelayList()
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -603,17 +616,94 @@ fun HomeScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewRequests(requests: List<Room>) {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val total = requests.size
|
||||||
|
val firstRoom = requests.getOrNull(0)
|
||||||
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
|
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
|
||||||
|
val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel)
|
||||||
|
|
||||||
|
val supportingText = when {
|
||||||
|
total == 1 -> {
|
||||||
|
val message = firstRoom.lastMessage ?: ""
|
||||||
|
"${firstRoomState.name}: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
total == 2 -> {
|
||||||
|
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
total > 2 -> {
|
||||||
|
val othersCount = total - 2
|
||||||
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
|
"${firstRoomState.name}, ${secondRoomState.name} 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 nostrViewModel = LocalNostrViewModel.current
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val roomState by room.rememberUiState(nostrViewModel)
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(picture = picture, description = displayName)
|
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
@@ -621,7 +711,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = roomState.name,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -635,8 +725,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
supportingContent = {
|
supportingContent = {
|
||||||
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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -652,12 +744,13 @@ fun BottomMenuList(
|
|||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
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 +776,14 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.logout() },
|
onClick = {
|
||||||
|
onDismiss {
|
||||||
|
authViewModel.logout(onLogout = {
|
||||||
|
nostrViewModel.resetInternalState()
|
||||||
|
chatViewModel.resetInternalState()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
@@ -65,7 +66,6 @@ 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.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -74,37 +74,35 @@ fun ImportScreen() {
|
|||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBusy = authState.isBusy
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
// Get metadata when pubkey changes
|
val profile by remember(pubkey) {
|
||||||
val metadata by remember(pubkey) {
|
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
|
||||||
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
|
|
||||||
}.collectAsStateWithLifecycle(null)
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
@@ -163,7 +161,7 @@ fun ImportScreen() {
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = "Profile picture",
|
description = "Profile picture",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
@@ -171,7 +169,7 @@ fun ImportScreen() {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
fontFamily = getExpressiveFontFamily()
|
fontFamily = getExpressiveFontFamily()
|
||||||
@@ -245,10 +243,10 @@ fun ImportScreen() {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (pubkey == null) {
|
if (pubkey == null) {
|
||||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
authViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
} else {
|
} else {
|
||||||
// Import the identity
|
// Import the identity
|
||||||
viewModel.importIdentity(secret)
|
authViewModel.importIdentity(secret)
|
||||||
// Navigate to the home screen
|
// Navigate to the home screen
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
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 io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
@@ -27,8 +29,8 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
fun MyQrScreen() {
|
fun MyQrScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -59,7 +61,7 @@ fun MyQrScreen() {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||||
contentDescription = "My QR"
|
contentDescription = "My QR"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_arrow_next
|
import coop.composeapp.generated.resources.ic_arrow_next
|
||||||
@@ -54,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalScanResult
|
import su.reya.coop.LocalScanResult
|
||||||
@@ -61,6 +63,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
|
||||||
@@ -68,17 +71,19 @@ fun NewChatScreen() {
|
|||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val qrScanResult = LocalScanResult.current
|
val qrScanResult = LocalScanResult.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -91,12 +96,12 @@ fun NewChatScreen() {
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = viewModel.searchByAddress(query)
|
val pubkey = nostrViewModel.searchByAddress(query)
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = viewModel.searchByNostr(query)
|
val results = nostrViewModel.searchByNostr(query)
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
@@ -108,14 +113,13 @@ fun NewChatScreen() {
|
|||||||
LaunchedEffect(qrScanResult.content) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrScanResult.content?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
// Verify the content
|
// Verify the content
|
||||||
runCatching { PublicKey.parse(result) }
|
runCatching {
|
||||||
.onSuccess { pubkey ->
|
PublicKey.parse(result)
|
||||||
selectedReceivers.add(pubkey)
|
}.onSuccess { pubkey ->
|
||||||
}
|
selectedReceivers.add(pubkey)
|
||||||
.onFailure { e ->
|
}.onFailure { e ->
|
||||||
println("Failed to parse QR: ${e.message}")
|
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
qrScanResult.clear()
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
@@ -167,7 +171,7 @@ fun NewChatScreen() {
|
|||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
expanded = false,
|
expanded = false,
|
||||||
@@ -258,7 +262,7 @@ fun NewChatScreen() {
|
|||||||
items = searchResults,
|
items = searchResults,
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -269,7 +273,7 @@ fun NewChatScreen() {
|
|||||||
items = contactList.toList(),
|
items = contactList.toList(),
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -286,13 +290,9 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -300,7 +300,7 @@ fun ReceiverChip(
|
|||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
@@ -308,8 +308,8 @@ fun ReceiverChip(
|
|||||||
},
|
},
|
||||||
avatar = {
|
avatar = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "No name",
|
||||||
size = 24.dp
|
size = 24.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -372,13 +372,9 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
@@ -390,15 +386,15 @@ fun ContactListItem(
|
|||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "",
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = { Text(text = pubkey.short()) },
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
content = {
|
content = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.ProfileEditor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen() {
|
fun NewIdentityScreen() {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val authViewModel = LocalAuthViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBusy = authState.isBusy
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Create a new identity",
|
title = "Create a new identity",
|
||||||
@@ -24,7 +26,7 @@ fun NewIdentityScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.createIdentity(name, bio, bytes, type)
|
authViewModel.createIdentity(name, bio, bytes, type)
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import su.reya.coop.LocalNavigator
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
@@ -57,7 +57,7 @@ fun OnboardingScreen() {
|
|||||||
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 authViewModel = LocalAuthViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
@@ -158,9 +158,9 @@ fun OnboardingScreen() {
|
|||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (viewModel.isExternalSignerAvailable()) {
|
if (authViewModel.isExternalSignerAvailable()) {
|
||||||
try {
|
try {
|
||||||
viewModel.connectExternalSigner()
|
authViewModel.connectExternalSigner()
|
||||||
navigator.navigate(Screen.Home)
|
navigator.navigate(Screen.Home)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -44,6 +44,7 @@ import coop.composeapp.generated.resources.ic_share
|
|||||||
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
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
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
|
||||||
@@ -60,21 +61,22 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
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 nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
val metadata = profile?.metadata?.asRecord()
|
||||||
|
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||||
|
val picture = metadata?.picture
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
listOf(
|
listOf(
|
||||||
"Username:" to (profile?.name ?: "None"),
|
"Username:" to (metadata?.name ?: "None"),
|
||||||
"Website:" to (profile?.website ?: "None"),
|
"Website:" to (metadata?.website ?: "None"),
|
||||||
"Lightning Address:" to (profile?.lud16 ?: "None"),
|
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +85,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(
|
||||||
@@ -132,7 +134,7 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
fontFamily = getExpressiveFontFamily()
|
fontFamily = getExpressiveFontFamily()
|
||||||
@@ -158,7 +160,8 @@ fun ProfileScreen(pubkey: String) {
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId =
|
||||||
|
chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navigator.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.message?.let { snackbarHostState.showSnackbar(it) }
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import su.reya.coop.LocalSnackbarHostState
|
|||||||
fun RelayScreen() {
|
fun RelayScreen() {
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
@@ -96,8 +96,8 @@ fun RelayScreen() {
|
|||||||
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(viewModel.currentUserRelayList())
|
relayList.putAll(nostrViewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -321,11 +321,11 @@ fun RelayScreen() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
viewModel.removeMsgRelay(relayToDelete!!)
|
nostrViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
msgRelayList.removeIf { it.toString() == relayToDelete }
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
relayToDelete = null
|
relayToDelete = null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
snackbarHostState.showSnackbar("Failed to remove relay")
|
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +349,7 @@ fun AddRelayDialog(
|
|||||||
onMsgRelayAdded: (newRelay: String) -> Unit,
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -401,17 +401,17 @@ fun AddRelayDialog(
|
|||||||
if (!isError) {
|
if (!isError) {
|
||||||
when (selected) {
|
when (selected) {
|
||||||
"Messaging" -> {
|
"Messaging" -> {
|
||||||
viewModel.addMsgRelay(relayAddress)
|
nostrViewModel.addMsgRelay(relayAddress)
|
||||||
onMsgRelayAdded(relayAddress)
|
onMsgRelayAdded(relayAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Inbox" -> {
|
"Inbox" -> {
|
||||||
viewModel.addInboxRelay(relayAddress)
|
nostrViewModel.addInboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
}
|
}
|
||||||
|
|
||||||
"Outbox" -> {
|
"Outbox" -> {
|
||||||
viewModel.addOutboxRelay(relayAddress)
|
nostrViewModel.addOutboxRelay(relayAddress)
|
||||||
onRelayAdded(relayAddress, RelayMetadata.READ)
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
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 chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val chatRooms by chatViewModel.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
|
||||||
|
chatViewModel.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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
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.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -12,15 +11,13 @@ import su.reya.coop.shared.ProfileEditor
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateProfileScreen() {
|
fun UpdateProfileScreen() {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
|
|
||||||
ProfileEditor(
|
ProfileEditor(
|
||||||
title = "Update profile",
|
title = "Update profile",
|
||||||
@@ -32,7 +29,7 @@ fun UpdateProfileScreen() {
|
|||||||
onBack = { navigator.goBack() },
|
onBack = { navigator.goBack() },
|
||||||
onConfirm = { name, bio, bytes, type ->
|
onConfirm = { name, bio, bytes, type ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.updateProfile(name, bio, bytes, type)
|
nostrViewModel.updateProfile(name, bio, bytes, type)
|
||||||
navigator.goBack()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_cancel
|
||||||
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.humanReadable
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScreenerCard(room: Room) {
|
||||||
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val nostrViewModel = 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 profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) {
|
||||||
|
scope.launch {
|
||||||
|
// Check contact
|
||||||
|
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
|
// Get mutual contacts
|
||||||
|
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
|
// Get the last activity
|
||||||
|
nostrViewModel.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 = profile?.picture,
|
||||||
|
description = "Profile picture",
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = profile?.name ?: "No name",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pubkey.short(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isContact) "Contact" else "Not a contact",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check_circle),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DateSeparator(date: String) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_add_circle
|
||||||
|
import coop.composeapp.generated.resources.ic_audio
|
||||||
|
import coop.composeapp.generated.resources.ic_send
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatInput(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onUpload: () -> Unit,
|
||||||
|
onMicClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = { Text("Message") },
|
||||||
|
leadingIcon = {
|
||||||
|
IconButton(onClick = onUpload) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_add_circle),
|
||||||
|
contentDescription = "Upload",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = value.isNotEmpty(),
|
||||||
|
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||||
|
label = "send_mic_transition"
|
||||||
|
) { isNotEmpty ->
|
||||||
|
if (isNotEmpty) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_send),
|
||||||
|
contentDescription = "Send"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onMicClick,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_audio),
|
||||||
|
contentDescription = "Speech to Text"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.URL_REGEX
|
||||||
|
import su.reya.coop.formatAsTime
|
||||||
|
import su.reya.coop.isImageUrl
|
||||||
|
import su.reya.coop.removeImageUrls
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MessageUiModel(
|
||||||
|
val id: String,
|
||||||
|
val annotatedContent: AnnotatedString,
|
||||||
|
val images: List<String>,
|
||||||
|
val timestamp: String,
|
||||||
|
val isMine: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberMessageUiModel(
|
||||||
|
event: UnsignedEvent,
|
||||||
|
currentUserPublicKey: PublicKey?,
|
||||||
|
contentColor: Color
|
||||||
|
): MessageUiModel {
|
||||||
|
return remember(event, currentUserPublicKey, contentColor) {
|
||||||
|
val content = event.content()
|
||||||
|
val images = URL_REGEX.findAll(content)
|
||||||
|
.map { it.value }
|
||||||
|
.filter { it.isImageUrl() }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val cleanedContent = content.removeImageUrls()
|
||||||
|
|
||||||
|
val annotatedString = buildAnnotatedString {
|
||||||
|
var lastIndex = 0
|
||||||
|
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
||||||
|
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
||||||
|
val url = matchResult.value
|
||||||
|
pushLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url = url,
|
||||||
|
styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = contentColor,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
append(url)
|
||||||
|
pop()
|
||||||
|
lastIndex = matchResult.range.last + 1
|
||||||
|
}
|
||||||
|
append(cleanedContent.substring(lastIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageUiModel(
|
||||||
|
id = event.id()?.toHex() ?: event.hashCode().toString(),
|
||||||
|
annotatedContent = annotatedString,
|
||||||
|
images = images,
|
||||||
|
timestamp = event.createdAt().formatAsTime(),
|
||||||
|
isMine = event.author() == currentUserPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatMessage(model: MessageUiModel) {
|
||||||
|
var isMessageClicked by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val bubbleShape = if (model.isMine) {
|
||||||
|
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val containerColor =
|
||||||
|
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
||||||
|
|
||||||
|
val contentColor =
|
||||||
|
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
isMessageClicked = !isMessageClicked
|
||||||
|
},
|
||||||
|
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (model.annotatedContent.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
shape = bubbleShape,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.annotatedContent,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.images.forEach { imageUrl ->
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = "Image from chat",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isMessageClicked,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.timestamp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.align(
|
||||||
|
if (model.isMine) Alignment.End else Alignment.Start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
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.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
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.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.formatAsGroupHeader
|
||||||
|
import su.reya.coop.rememberUiState
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Get chat room by ID
|
||||||
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
|
// Show empty screen
|
||||||
|
if (room == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Something went wrong.",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
var requireScreening by remember { mutableStateOf(screening) }
|
||||||
|
|
||||||
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
|
val groupedMessages =
|
||||||
|
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
||||||
|
|
||||||
|
val sendFile = { uri: Uri ->
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// Read file
|
||||||
|
val file = withContext(Dispatchers.IO) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file content type
|
||||||
|
val type = context.contentResolver.getType(uri)
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
chatViewModel.sendFileMessage(id, file, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
uri?.let { sendFile(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sttLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
val data = result.data
|
||||||
|
val results = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||||
|
if (!results.isNullOrEmpty()) text = results[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(id) {
|
||||||
|
// Get messages
|
||||||
|
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||||
|
messages.clear()
|
||||||
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
|
// Stop loading spinner
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
// Get msg relays for each member
|
||||||
|
chatViewModel.chatRoomConnect(id)
|
||||||
|
|
||||||
|
// Handle new messages
|
||||||
|
chatViewModel.newEvents.collect { event ->
|
||||||
|
if (event.roomId() == id) {
|
||||||
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
|
messages.add(0, event)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the event is not in the current room, it's a new message from another user
|
||||||
|
newOtherMessages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
room?.members?.firstOrNull()?.let { pubkey ->
|
||||||
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (loading) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
|
} else {
|
||||||
|
Avatar(
|
||||||
|
picture = roomState.picture,
|
||||||
|
description = roomState.name,
|
||||||
|
size = 32.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = roomState.name,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (newOtherMessages > 0) {
|
||||||
|
Badge {
|
||||||
|
Text(newOtherMessages.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
|
) {
|
||||||
|
if (requireScreening) {
|
||||||
|
room?.let { ScreenerCard(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
val otherColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
when (messages.isNotEmpty()) {
|
||||||
|
true -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
reverseLayout = true,
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
||||||
|
items(
|
||||||
|
items = messagesInGroup,
|
||||||
|
key = { it.id()?.toHex() ?: it.hashCode().toString() }
|
||||||
|
) { event ->
|
||||||
|
val isMine = currentUser?.publicKey == event.author()
|
||||||
|
val uiModel = rememberMessageUiModel(
|
||||||
|
event = event,
|
||||||
|
currentUserPublicKey = currentUser?.publicKey,
|
||||||
|
contentColor = if (isMine) mineColor else otherColor
|
||||||
|
)
|
||||||
|
ChatMessage(model = uiModel)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DateSeparator(dateHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No messages yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your conversations will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (requireScreening) {
|
||||||
|
true -> {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { navigator.goBack() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Reject",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { requireScreening = false },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Accept",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ChatInput(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
onSend = {
|
||||||
|
chatViewModel.sendMessage(id, text)
|
||||||
|
text = ""
|
||||||
|
},
|
||||||
|
onUpload = {
|
||||||
|
fileLauncher.launch("image/*")
|
||||||
|
},
|
||||||
|
onMicClick = {
|
||||||
|
val intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||||
|
)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sttLauncher.launch(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar("Speech recognition not available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
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.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -53,7 +54,6 @@ import coil3.compose.AsyncImage
|
|||||||
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_plus
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -75,6 +75,8 @@ fun ProfileEditor(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var name by remember(initialName) { mutableStateOf(initialName) }
|
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||||
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||||
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||||
@@ -264,7 +266,6 @@ fun ProfileEditor(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.size(ButtonDefaults.MediumContainerHeight),
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
onClick = {
|
onClick = {
|
||||||
val scope = CoroutineScope(Dispatchers.Main)
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
(picture as? Uri)?.let {
|
(picture as? Uri)?.let {
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package su.reya.coop.shared
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import su.reya.coop.NostrViewModel
|
|
||||||
import su.reya.coop.Room
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
|
||||||
// Return early if there's a custom subject/room name
|
|
||||||
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
|
||||||
|
|
||||||
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
|
||||||
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
|
||||||
|
|
||||||
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
|
|
||||||
val names = metadataArray.mapIndexed { i, metadata ->
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
profile?.name?.takeIf { it.isNotBlank() }
|
|
||||||
?: profile?.displayName?.takeIf { it.isNotBlank() }
|
|
||||||
?: displayMembers[i].short()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGroup()) {
|
|
||||||
val combined = names.joinToString(", ")
|
|
||||||
val extraCount = members.size - names.size
|
|
||||||
if (extraCount > 0) "$combined, +$extraCount" else combined
|
|
||||||
} else {
|
|
||||||
val name = names.first()
|
|
||||||
if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
|
||||||
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
|
||||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,14 @@ androidx-espresso = "3.7.0"
|
|||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
androidx-splashscreen = "1.2.0"
|
androidx-splashscreen = "1.2.0"
|
||||||
composeMultiplatform = "1.11.0"
|
composeMultiplatform = "1.11.1"
|
||||||
datastorePreferences = "1.2.1"
|
datastorePreferences = "1.2.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.0"
|
||||||
kotlinx-serialization = "1.11.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
material3 = "1.11.0-alpha07"
|
material3 = "1.11.0-alpha07"
|
||||||
multiplatform-nav3-ui = "1.1.1"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
ktor = "3.5.0"
|
ktor = "3.5.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +33,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
||||||
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.2")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.17.0")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|||||||
26
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
26
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
|
||||||
|
fun PublicKey.short(): String {
|
||||||
|
val bech32 = toBech32()
|
||||||
|
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
||||||
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
|
fun String.extractUrls(): List<String> {
|
||||||
|
return URL_REGEX.findAll(this).map { it.value }.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.removeImageUrls(): String {
|
||||||
|
return URL_REGEX.replace(this) { result ->
|
||||||
|
if (result.value.isImageUrl()) "" else result.value
|
||||||
|
}.replace(Regex("\\s+"), " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.isImageUrl(): Boolean {
|
||||||
|
val extension = this.substringAfterLast('.', "").lowercase()
|
||||||
|
return extension in imageExtensions
|
||||||
|
}
|
||||||
@@ -1,913 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.statement.HttpResponse
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.supervisorScope
|
|
||||||
import rust.nostr.sdk.AckPolicy
|
|
||||||
import rust.nostr.sdk.Alphabet
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
|
||||||
import rust.nostr.sdk.Client
|
|
||||||
import rust.nostr.sdk.ClientBuilder
|
|
||||||
import rust.nostr.sdk.ClientNotification
|
|
||||||
import rust.nostr.sdk.Contact
|
|
||||||
import rust.nostr.sdk.Event
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Filter
|
|
||||||
import rust.nostr.sdk.GossipConfig
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.LogLevel
|
|
||||||
import rust.nostr.sdk.Metadata
|
|
||||||
import rust.nostr.sdk.MetadataRecord
|
|
||||||
import rust.nostr.sdk.Nip05Address
|
|
||||||
import rust.nostr.sdk.Nip05Profile
|
|
||||||
import rust.nostr.sdk.NostrDatabase
|
|
||||||
import rust.nostr.sdk.NostrGossip
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayCapabilities
|
|
||||||
import rust.nostr.sdk.RelayMessageEnum
|
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayStatus
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.ReqExitPolicy
|
|
||||||
import rust.nostr.sdk.ReqTarget
|
|
||||||
import rust.nostr.sdk.SendEventTarget
|
|
||||||
import rust.nostr.sdk.SignerAuthenticator
|
|
||||||
import rust.nostr.sdk.SingleLetterTag
|
|
||||||
import rust.nostr.sdk.SleepWhenIdle
|
|
||||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
|
||||||
import rust.nostr.sdk.extractRelayList
|
|
||||||
import rust.nostr.sdk.initLogger
|
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
|
||||||
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
|
||||||
import kotlin.time.Duration
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
object NostrManager {
|
|
||||||
val instance = Nostr()
|
|
||||||
|
|
||||||
val BOOTSTRAP_RELAYS = listOf(
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://purplepag.es"
|
|
||||||
)
|
|
||||||
|
|
||||||
val INDEXER_RELAY = listOf(
|
|
||||||
"wss://indexer.coracle.social",
|
|
||||||
)
|
|
||||||
|
|
||||||
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
|
||||||
}
|
|
||||||
|
|
||||||
class Nostr {
|
|
||||||
var client: Client? = null
|
|
||||||
private set
|
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
|
||||||
private set
|
|
||||||
var deviceSigner: AsyncNostrSigner? = null
|
|
||||||
private set
|
|
||||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
|
||||||
private set
|
|
||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val isInitialized = MutableStateFlow(false)
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _metadataUpdates =
|
|
||||||
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
|
||||||
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
|
||||||
|
|
||||||
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
|
||||||
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
|
||||||
|
|
||||||
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
|
|
||||||
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
|
|
||||||
|
|
||||||
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event)
|
|
||||||
|
|
||||||
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit)
|
|
||||||
|
|
||||||
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
|
|
||||||
_metadataUpdates.emit(pubkey to metadata)
|
|
||||||
|
|
||||||
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
|
||||||
_contactListUpdates.emit(contacts)
|
|
||||||
|
|
||||||
suspend fun init(
|
|
||||||
dbPath: String,
|
|
||||||
logLevel: LogLevel = LogLevel.WARN
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
if (isInitialized.value) return
|
|
||||||
|
|
||||||
// Initialize the logger for nostr client
|
|
||||||
initLogger(logLevel)
|
|
||||||
|
|
||||||
// Initialize configurations for nostr client
|
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
|
||||||
val gossip = NostrGossip.inMemory()
|
|
||||||
val authenticator = SignerAuthenticator(signer)
|
|
||||||
val idleTimeout = Duration.parse("5m")
|
|
||||||
|
|
||||||
client =
|
|
||||||
ClientBuilder()
|
|
||||||
.authenticator(authenticator)
|
|
||||||
.database(lmdb)
|
|
||||||
.gossip(gossip)
|
|
||||||
.gossipConfig(
|
|
||||||
GossipConfig()
|
|
||||||
.noBackgroundRefresh()
|
|
||||||
.fetchTimeout(Duration.parse("2s"))
|
|
||||||
)
|
|
||||||
.verifySubscriptions(false)
|
|
||||||
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
isInitialized.value = true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun waitUntilInitialized() {
|
|
||||||
isInitialized.first { it }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
|
||||||
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
|
||||||
client?.addRelay(RelayUrl.parse(url))
|
|
||||||
}
|
|
||||||
NostrManager.INDEXER_RELAY.forEach { url ->
|
|
||||||
client?.addRelay(
|
|
||||||
url = RelayUrl.parse(url),
|
|
||||||
capabilities = RelayCapabilities.gossip()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Connect to all bootstrap relays
|
|
||||||
client?.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun reconnect() {
|
|
||||||
NostrManager.ALL_RELAYS.forEach { url ->
|
|
||||||
try {
|
|
||||||
client?.relay(RelayUrl.parse(url)).let { relay ->
|
|
||||||
if (relay != null) {
|
|
||||||
if (relay.status() != RelayStatus.CONNECTED) {
|
|
||||||
relay.connect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to reconnect relay: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disconnect() {
|
|
||||||
NostrManager.ALL_RELAYS.forEach { url ->
|
|
||||||
try {
|
|
||||||
client?.disconnectRelay(RelayUrl.parse(url))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to disconnect relay: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
|
||||||
try {
|
|
||||||
signer.switch(new)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isSignedByUser(event: Event): Boolean {
|
|
||||||
return try {
|
|
||||||
signer.currentUser == event.author()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to check if event is signed by user: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getUserMetadata() {
|
|
||||||
try {
|
|
||||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
// Get the latest metadata event
|
|
||||||
val metadataFilter =
|
|
||||||
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
|
||||||
|
|
||||||
// Get the latest contact list event
|
|
||||||
val contactFilter =
|
|
||||||
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
|
||||||
|
|
||||||
// Get the latest messaging relay list event
|
|
||||||
val msgRelayFilter =
|
|
||||||
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
|
||||||
|
|
||||||
// Construct a target that includes all filters
|
|
||||||
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getUserMessages(msgRelayList: Event) {
|
|
||||||
try {
|
|
||||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
val relays = nip17ExtractRelayList(msgRelayList)
|
|
||||||
|
|
||||||
// Ensure relay connections
|
|
||||||
relays.forEach { relay ->
|
|
||||||
client?.addRelay(relay)
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct a filter for gift wrap events
|
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
|
||||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
|
||||||
relays.forEach { relay ->
|
|
||||||
target[relay] = listOf(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
client?.subscribe(
|
|
||||||
target = ReqTarget.manual(target),
|
|
||||||
id = "gift-wraps"
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun handleNotifications(
|
|
||||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
|
||||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
|
||||||
onNewMessage: (UnsignedEvent) -> Unit,
|
|
||||||
onSubscriptionClose: () -> Unit,
|
|
||||||
) = supervisorScope {
|
|
||||||
val now = Timestamp.now()
|
|
||||||
val processedEvent = mutableSetOf<EventId>()
|
|
||||||
val notifications = client?.notifications() ?: return@supervisorScope
|
|
||||||
|
|
||||||
var eoseTrackerJob: Job? = null
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val notification = notifications.next() ?: continue
|
|
||||||
|
|
||||||
when (notification) {
|
|
||||||
is ClientNotification.Message -> {
|
|
||||||
val relayUrl = notification.relayUrl
|
|
||||||
|
|
||||||
when (val message = notification.message.asEnum()) {
|
|
||||||
is RelayMessageEnum.EventMsg -> {
|
|
||||||
val event = message.event
|
|
||||||
|
|
||||||
// Prevent processing duplicate events
|
|
||||||
if (processedEvent.contains(event.id())) continue
|
|
||||||
processedEvent.add(event.id())
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
|
|
||||||
try {
|
|
||||||
val metadata = Metadata.fromJson(event.content())
|
|
||||||
onMetadataUpdate(event.author(), metadata)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to parse metadata: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
|
||||||
if (isSignedByUser(event = event)) {
|
|
||||||
onContactListUpdate(event.tags().publicKeys())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
|
||||||
// Get all gift wrap events for the current user
|
|
||||||
if (isSignedByUser(event = event)) {
|
|
||||||
getUserMessages(msgRelayList = event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
|
||||||
try {
|
|
||||||
val rumor = extractRumor(event)
|
|
||||||
|
|
||||||
// Logic to notify UI after processing
|
|
||||||
// Cancel previous tracker if it exists
|
|
||||||
eoseTrackerJob?.cancel()
|
|
||||||
// Start a new tracker
|
|
||||||
eoseTrackerJob = launch {
|
|
||||||
delay(10000.milliseconds) // Wait for 10 seconds
|
|
||||||
onSubscriptionClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle new message
|
|
||||||
rumor?.createdAt()?.asSecs()?.let {
|
|
||||||
if (it >= now.asSecs()) {
|
|
||||||
onNewMessage(rumor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to extract rumor: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
|
||||||
val subscriptionId = message.subscriptionId
|
|
||||||
|
|
||||||
if (subscriptionId == "gift-wraps") {
|
|
||||||
onSubscriptionClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is RelayMessageEnum.Ok -> {
|
|
||||||
if (sentEvents.containsKey(message.eventId)) {
|
|
||||||
val currentRelays = sentEvents[message.eventId] ?: emptyList()
|
|
||||||
sentEvents[message.eventId] = currentRelays + relayUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
/* Ignore other message types */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ClientNotification.Shutdown -> {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
/* Ignore other message types */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
|
||||||
try {
|
|
||||||
val filter = Filter().identifier(giftId.toHex())
|
|
||||||
val event = client?.database()?.query(filter)?.first()
|
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
|
||||||
try {
|
|
||||||
// Construct the room id
|
|
||||||
val roomId = rumor.roomId()
|
|
||||||
|
|
||||||
// Construct reference tags
|
|
||||||
val tags = listOf(
|
|
||||||
Tag.identifier(giftId.toHex()),
|
|
||||||
Tag.publicKey(rumor.author()),
|
|
||||||
Tag.event(rumor.id()!!),
|
|
||||||
Tag.custom("r", listOf(roomId.toString())),
|
|
||||||
Tag.custom("k", listOf("14"))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set event kind
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
|
||||||
|
|
||||||
// Construct event
|
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
|
||||||
.tags(tags)
|
|
||||||
.finalizeAsync(Keys.generate())
|
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
|
||||||
try {
|
|
||||||
// Check if the rumor is already cached
|
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
|
||||||
if (cachedRumor != null) return cachedRumor
|
|
||||||
|
|
||||||
// Unwrap the gift with current signer
|
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
|
||||||
val rumor = gift.rumor()
|
|
||||||
|
|
||||||
// Save the rumor to the database
|
|
||||||
setCachedRumor(event.id(), rumor)
|
|
||||||
|
|
||||||
// Return the rumor
|
|
||||||
return rumor
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to unwrap gift: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
|
||||||
// Construct a list of relays
|
|
||||||
val relayList = mapOf<RelayUrl, RelayMetadata>(
|
|
||||||
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
|
||||||
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
|
||||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
|
||||||
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ensure all relays are added and connected
|
|
||||||
relayList.forEach { (relay, metadata) ->
|
|
||||||
client?.addRelay(
|
|
||||||
url = relay,
|
|
||||||
capabilities =
|
|
||||||
when (metadata) {
|
|
||||||
RelayMetadata.READ -> RelayCapabilities.read()
|
|
||||||
RelayMetadata.WRITE -> RelayCapabilities.write()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
return relayList
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
|
||||||
// Construct a list of messaging relays
|
|
||||||
val msgRelayList = listOf(
|
|
||||||
RelayUrl.parse("wss://auth.nostr1.com"),
|
|
||||||
RelayUrl.parse("wss://nip17.com"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ensure all relays are added and connected
|
|
||||||
msgRelayList.forEach { relay ->
|
|
||||||
client?.addRelay(relay, RelayCapabilities.none())
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
return msgRelayList
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
|
||||||
// Send relay list event
|
|
||||||
val relayList = getDefaultRelayList()
|
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = relayListEvent,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.all(),
|
|
||||||
okTimeout = Duration.parse("3s")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send messaging relay list event
|
|
||||||
val msgRelayList = getDefaultMsgRelayList()
|
|
||||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = msgRelayListEvent,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send metadata event
|
|
||||||
val metadata =
|
|
||||||
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
|
||||||
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = metadataEvent,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send contact list event
|
|
||||||
val defaultContact =
|
|
||||||
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
|
||||||
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = contactListEvent,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
setSigner(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProfile(
|
|
||||||
name: String? = null,
|
|
||||||
bio: String? = null,
|
|
||||||
picture: String? = null
|
|
||||||
): Metadata {
|
|
||||||
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
try {
|
|
||||||
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
|
||||||
val newRecord = record.copy(
|
|
||||||
displayName = name ?: record.displayName,
|
|
||||||
about = bio ?: record.about,
|
|
||||||
picture = picture ?: record.picture
|
|
||||||
)
|
|
||||||
val newMetadata = Metadata.fromRecord(newRecord)
|
|
||||||
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = event,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
return newMetadata
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
|
||||||
return try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.METADATA);
|
|
||||||
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
|
||||||
val event = client?.database()?.query(filter)?.first() ?: return null
|
|
||||||
|
|
||||||
Metadata.fromJson(event.content())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to get latest metadata: ${e.message}")
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
|
||||||
try {
|
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
val results = mutableMapOf<PublicKey, Metadata>()
|
|
||||||
|
|
||||||
events?.toVec()?.forEach { event ->
|
|
||||||
try {
|
|
||||||
val metadata = Metadata.fromJson(event.content())
|
|
||||||
results[event.author()] = metadata
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to parse metadata: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to get all cache metadata: ${e.message}")
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
|
||||||
try {
|
|
||||||
val limit = keys.size.toULong() * 4u;
|
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
// Construct a filter for metadata events
|
|
||||||
val filter = Filter()
|
|
||||||
.kind(Kind.fromStd(KindStandard.METADATA))
|
|
||||||
.authors(keys)
|
|
||||||
.limit(limit)
|
|
||||||
|
|
||||||
// Construct a target that includes all filters
|
|
||||||
val target =
|
|
||||||
ReqTarget.manual(
|
|
||||||
mapOf(
|
|
||||||
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
|
|
||||||
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
client?.subscribe(target = target, closeOn = opts)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
|
||||||
try {
|
|
||||||
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = event,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
|
|
||||||
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
|
||||||
val target = ReqTarget.auto(listOf(filter))
|
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
client?.subscribe(target = target, closeOn = opts)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
|
||||||
val target = ReqTarget.auto(listOf(filter))
|
|
||||||
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
|
||||||
|
|
||||||
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
|
||||||
try {
|
|
||||||
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = event,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.none(),
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRooms(): Set<Room>? {
|
|
||||||
try {
|
|
||||||
val userPubkey =
|
|
||||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
|
||||||
|
|
||||||
// Get all events sent by the user
|
|
||||||
val filter = Filter().kind(kind).pubkey(userPubkey).customTags(kTag, listOf("14", "dm"))
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
// Collect rooms
|
|
||||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
|
||||||
|
|
||||||
events
|
|
||||||
?.toVec()
|
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
|
||||||
?.forEach { event ->
|
|
||||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
|
||||||
val existingRoom = roomsMap[newRoom.id]
|
|
||||||
|
|
||||||
// Check if the room already exists
|
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
|
||||||
val kind = Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE)
|
|
||||||
val pubkeys = newRoom.members.toList()
|
|
||||||
val filter = Filter().kind(kind).author(userPubkey).pubkeys(pubkeys)
|
|
||||||
|
|
||||||
// Determine if it's an ongoing room
|
|
||||||
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
|
|
||||||
|
|
||||||
// Append room to map
|
|
||||||
roomsMap[newRoom.id] =
|
|
||||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to get chat rooms: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
|
||||||
val filter = Filter().kind(kind).reference(roomId.toString())
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
// Merge the events
|
|
||||||
return events
|
|
||||||
?.toVec()
|
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
|
||||||
// Filter out events without public keys (receivers)
|
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
|
||||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
|
||||||
try {
|
|
||||||
members.forEach { member ->
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
|
||||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
|
||||||
target = ReqTarget.auto(listOf(filter)),
|
|
||||||
id = null,
|
|
||||||
timeout = Duration.parse("3s"),
|
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
|
||||||
)
|
|
||||||
|
|
||||||
stream?.next()?.let { res ->
|
|
||||||
if (res.event != null) {
|
|
||||||
connectMsgRelays(res.event!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectMsgRelays(event: Event) {
|
|
||||||
try {
|
|
||||||
val urls = nip17ExtractRelayList(event);
|
|
||||||
for (url in urls) {
|
|
||||||
client?.addRelay(url, RelayCapabilities.gossip())
|
|
||||||
client?.connectRelay(url)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendMessage(
|
|
||||||
to: Set<PublicKey>,
|
|
||||||
content: String,
|
|
||||||
subject: String? = null,
|
|
||||||
replies: List<EventId> = emptyList(),
|
|
||||||
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val tags = mutableListOf<Tag>()
|
|
||||||
|
|
||||||
// Add a subject tag if provided
|
|
||||||
if (subject != null) {
|
|
||||||
tags.add(Tag.custom("subject", listOf(subject)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event tags for replies
|
|
||||||
if (replies.isNotEmpty()) {
|
|
||||||
replies.forEach { replyId ->
|
|
||||||
tags.add(Tag.event(replyId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add public key tags for each recipient
|
|
||||||
to.forEach { pubkey ->
|
|
||||||
tags.add(Tag.publicKey(pubkey))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (receiver in setOf(currentUser) + to) {
|
|
||||||
// Construct the rumor event
|
|
||||||
// NEVER SIGN this event with the current user signer
|
|
||||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
|
||||||
.tags(tags)
|
|
||||||
.finalizeUnsigned(currentUser)
|
|
||||||
.ensureId()
|
|
||||||
|
|
||||||
// Emit the rumor to the chat screen
|
|
||||||
if (receiver == currentUser) {
|
|
||||||
onRumorCreated?.invoke(rumor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the gift wrap event
|
|
||||||
val gift = nip59MakeGiftWrapAsync(
|
|
||||||
signer = signer,
|
|
||||||
receiverPubkey = receiver,
|
|
||||||
rumor = rumor,
|
|
||||||
extraTags = listOf(
|
|
||||||
Tag.custom("k", listOf("14"))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send the event to receiver's NIP-17 relays
|
|
||||||
val output = client?.sendEvent(
|
|
||||||
event = gift,
|
|
||||||
target = SendEventTarget.toNip17(),
|
|
||||||
ackPolicy = AckPolicy.none(),
|
|
||||||
authenticationTimeout = Duration.parse("2s")
|
|
||||||
)
|
|
||||||
|
|
||||||
if (output != null) {
|
|
||||||
// Keep track of sent events
|
|
||||||
sentEvents[output.id] = emptyList()
|
|
||||||
if (rumor.id() != null) rumorMap[rumor.id()!!] = output.id
|
|
||||||
|
|
||||||
// Collect failed outputs
|
|
||||||
output.failed.forEach { (relayUrl, reason) ->
|
|
||||||
println("Failed to send event to relay $relayUrl: $reason")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
|
||||||
try {
|
|
||||||
val response: HttpResponse = client.get(address.url())
|
|
||||||
val bodyString: String = response.body()
|
|
||||||
|
|
||||||
return Nip05Profile.fromJson(address, bodyString)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey {
|
|
||||||
try {
|
|
||||||
val address = Nip05Address.parse(query)
|
|
||||||
val profile = profileFromAddress(HttpClient(), address)
|
|
||||||
|
|
||||||
return profile.publicKey()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
// Add search relay
|
|
||||||
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
|
|
||||||
if (client?.relay(searchRelay) == null) {
|
|
||||||
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
|
|
||||||
client?.connectRelay(searchRelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
|
||||||
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
|
||||||
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
|
||||||
target = target,
|
|
||||||
id = "search",
|
|
||||||
timeout = Duration.parse("3s"),
|
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
|
||||||
)
|
|
||||||
|
|
||||||
// Collect the results
|
|
||||||
val results = mutableListOf<PublicKey>()
|
|
||||||
|
|
||||||
// Keep searching until the stream is closed or timeout
|
|
||||||
stream?.next()?.let { event ->
|
|
||||||
if (event.event != null) {
|
|
||||||
results.add(event.event!!.author())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,780 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.Metadata
|
|
||||||
import rust.nostr.sdk.NostrConnect
|
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.blossom.BlossomClient
|
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
|
|
||||||
class NostrViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val secretStore: SecretStorage,
|
|
||||||
private val externalSignerHandler: ExternalSignerHandler? = null,
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
|
||||||
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
|
||||||
|
|
||||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
|
||||||
val signerRequired = _signerRequired.asStateFlow()
|
|
||||||
|
|
||||||
private val _isBusy = MutableStateFlow(false)
|
|
||||||
val isBusy = _isBusy.asStateFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
|
||||||
|
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
|
||||||
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
|
||||||
val sentReport = _sentReports.asSharedFlow()
|
|
||||||
|
|
||||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
|
||||||
val errorEvents = _errorEvents.receiveAsFlow()
|
|
||||||
|
|
||||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
// Skip the splash screen if a user is already logged in
|
|
||||||
if (nostr.signer.currentUser != null) {
|
|
||||||
_signerRequired.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the notification banner has been dismissed
|
|
||||||
checkNotificationBannerDismissedStatus()
|
|
||||||
|
|
||||||
// Check local stored secret (secret key or bunker)
|
|
||||||
login()
|
|
||||||
|
|
||||||
// Automatically reconnect bootstrap relays
|
|
||||||
reconnect()
|
|
||||||
|
|
||||||
// Observe the signer state and verify the relay list
|
|
||||||
observeSignerAndCheckRelays()
|
|
||||||
|
|
||||||
// Get all local stored metadata
|
|
||||||
getCacheMetadata()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun bindLifecycle(lifecycle: Lifecycle) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
|
||||||
coroutineScope {
|
|
||||||
launch { refreshChatRooms() }
|
|
||||||
launch { runObserver() }
|
|
||||||
launch { runMetadataBatching() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
|
|
||||||
// Disconnect to all bootstrap relays
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
nostr.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showError(message: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_errorEvents.send(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun checkNotificationBannerDismissedStatus() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_isNotificationBannerDismissed.value =
|
|
||||||
secretStore.get("notification_banner_dismissed") == "true"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reconnect() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
nostr.reconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runObserver() = coroutineScope {
|
|
||||||
// Observe new messages
|
|
||||||
launch {
|
|
||||||
nostr.newEvents.collect { event ->
|
|
||||||
val roomId = event.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
|
||||||
|
|
||||||
if (existingRoom == null) {
|
|
||||||
val currentUser = nostr.signer.currentUser
|
|
||||||
if (currentUser != null) {
|
|
||||||
val newRoom = Room.new(event, currentUser)
|
|
||||||
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
_newEvents.emit(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe contact list updates
|
|
||||||
launch {
|
|
||||||
nostr.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe metadata updates
|
|
||||||
launch {
|
|
||||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observes subscription close
|
|
||||||
launch {
|
|
||||||
nostr.subscriptionClosed.collect {
|
|
||||||
getChatRooms()
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun runMetadataBatching() = coroutineScope {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
val batch = mutableSetOf<PublicKey>()
|
|
||||||
val timeout = 500L // 500ms timeout for batching
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val firstKey = metadataRequestChannel.receive()
|
|
||||||
batch.add(firstKey)
|
|
||||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
while (batch.isNotEmpty()) {
|
|
||||||
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
|
||||||
metadataRequestChannel.receive()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add the key if it's not null
|
|
||||||
if (nextKey != null) batch.add(nextKey)
|
|
||||||
|
|
||||||
// Get current time
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
// Check if the batch is full or timeout has passed
|
|
||||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
|
||||||
val keysToRequest = batch.toList()
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
nostr.fetchMetadataBatch(keysToRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
val results = nostr.getAllCacheMetadata()
|
|
||||||
results.forEach { (pubkey, metadata) ->
|
|
||||||
// Update the metadata state
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
// Update seenPublicKeys to avoid duplicate requests
|
|
||||||
seenPublicKeys.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val secret = withTimeoutOrNull(3.seconds) {
|
|
||||||
secretStore.get("user_signer")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (secret == null) {
|
|
||||||
_signerRequired.value = true
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
runCatching {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
}.onSuccess {
|
|
||||||
_signerRequired.value = false
|
|
||||||
}.onFailure { e ->
|
|
||||||
showError("Login failed: ${e.message}")
|
|
||||||
_signerRequired.value = true
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Login failed: ${e.message}")
|
|
||||||
_signerRequired.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeSignerAndCheckRelays() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
while (true) {
|
|
||||||
val pubkey = nostr.signer.currentUser
|
|
||||||
|
|
||||||
if (pubkey != null) {
|
|
||||||
// Get chat rooms
|
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
|
||||||
if (rooms.isNotEmpty()) {
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all metadata for the current user
|
|
||||||
nostr.getUserMetadata()
|
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
|
||||||
delay(2.seconds)
|
|
||||||
|
|
||||||
// Check if the relay list is empty
|
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
|
||||||
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(500.milliseconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMetadata(pubkey: PublicKey) {
|
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
metadataRequestChannel.send(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
|
||||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
|
||||||
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
|
||||||
if (flow.value == null) {
|
|
||||||
requestMetadata(pubkey)
|
|
||||||
}
|
|
||||||
return flow.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun currentUser(): PublicKey? {
|
|
||||||
return nostr.signer.currentUser
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
secretStore.clear("user_signer")
|
|
||||||
nostr.signer.switch(Keys.generate())
|
|
||||||
_signerRequired.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissNotificationBanner() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
secretStore.set("notification_banner_dismissed", "true")
|
|
||||||
_isNotificationBannerDismissed.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
|
||||||
val secret = secretStore.get("app_keys")
|
|
||||||
|
|
||||||
// If app keys are already stored, use them
|
|
||||||
if (secret != null) {
|
|
||||||
return Keys.parse(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new app keys and save to the secret storage
|
|
||||||
val keys = Keys.generate()
|
|
||||||
secretStore.set("app_keys", keys.secretKey().toBech32())
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
|
|
||||||
try {
|
|
||||||
// Upload picture to Blossom
|
|
||||||
val blossom = BlossomClient(
|
|
||||||
url = "https://blossom.band",
|
|
||||||
client = HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
|
||||||
file = file,
|
|
||||||
contentType = contentType,
|
|
||||||
signer = nostr.signer.get()
|
|
||||||
)
|
|
||||||
|
|
||||||
return descriptor?.url
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun updateProfile(
|
|
||||||
name: String? = null,
|
|
||||||
bio: String? = null,
|
|
||||||
picture: ByteArray? = null,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
_isBusy.value = true
|
|
||||||
try {
|
|
||||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
|
||||||
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
|
|
||||||
// Update the metadata state after successfully published
|
|
||||||
updateMetadata(nostr.signer.currentUser!!, newMetadata)
|
|
||||||
// Update local state
|
|
||||||
_isBusy.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
_isBusy.value = true
|
|
||||||
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
|
||||||
// Create identity
|
|
||||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
|
||||||
// Persist the secret in the secret storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
// Update local states
|
|
||||||
_isBusy.value = false
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
|
||||||
return when {
|
|
||||||
secret.startsWith("nsec1") -> Keys.parse(secret)
|
|
||||||
|
|
||||||
secret.startsWith("bunker://") -> {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = 50.seconds // or Duration.parse("50s")
|
|
||||||
NostrConnect(uri = bunker, appKeys, timeout, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.startsWith("nip55://") -> {
|
|
||||||
val handler = externalSignerHandler
|
|
||||||
?: throw IllegalStateException("External signer not available on this platform")
|
|
||||||
|
|
||||||
// Format: nip55://packageName/hexPubkey
|
|
||||||
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
|
||||||
val packageName = parts[0]
|
|
||||||
val pubkey = PublicKey.parse(parts[1])
|
|
||||||
|
|
||||||
handler.setPackageName(packageName)
|
|
||||||
ExternalSignerProxy(handler, pubkey)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Invalid secret format")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
if (secret.startsWith("bunker://")) {
|
|
||||||
showError("Please approve the connection.")
|
|
||||||
}
|
|
||||||
return signer.getPublicKeyAsync()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun importIdentity(secret: String) {
|
|
||||||
_isBusy.value = true
|
|
||||||
try {
|
|
||||||
val signer = createSigner(secret)
|
|
||||||
// Update signer
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
// Persist the secret in the secret storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
// Update local states
|
|
||||||
_signerRequired.value = false
|
|
||||||
_isBusy.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectExternalSigner() {
|
|
||||||
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
|
||||||
_isBusy.value = true
|
|
||||||
try {
|
|
||||||
val permissions = SignerPermissions.toJson(
|
|
||||||
listOf(
|
|
||||||
SignerPermissions.signEvent(0),
|
|
||||||
SignerPermissions.signEvent(3),
|
|
||||||
SignerPermissions.signEvent(10000),
|
|
||||||
SignerPermissions.signEvent(10050),
|
|
||||||
SignerPermissions.signEvent(10063),
|
|
||||||
SignerPermissions.signEvent(22242),
|
|
||||||
SignerPermissions.signEvent(30030),
|
|
||||||
SignerPermissions.signEvent(30315),
|
|
||||||
SignerPermissions.nip44Encrypt(),
|
|
||||||
SignerPermissions.nip44Decrypt(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
|
||||||
val signer = ExternalSignerProxy(handler, result.pubkey)
|
|
||||||
|
|
||||||
// Update signer
|
|
||||||
nostr.setSigner(signer)
|
|
||||||
// Store the signer in the secret storage
|
|
||||||
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
|
||||||
// Update local states
|
|
||||||
_signerRequired.value = false
|
|
||||||
_isBusy.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw Exception("Notice: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isExternalSignerAvailable(): Boolean {
|
|
||||||
return externalSignerHandler?.isAvailable() == true
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
|
||||||
val relays = nostr.fetchMsgRelays(pubkey)
|
|
||||||
if (relays.isNotEmpty()) dismissRelayWarning()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun useDefaultMsgRelayList() {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.getDefaultMsgRelayList()
|
|
||||||
nostr.setMsgRelays(defaultRelays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
return nostr.getRelayList(nostr.signer.currentUser!!)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addInboxRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays[relayUrl] = RelayMetadata.WRITE
|
|
||||||
|
|
||||||
nostr.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addOutboxRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays[relayUrl] = RelayMetadata.READ
|
|
||||||
|
|
||||||
nostr.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserRelayList().toMutableMap()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
|
|
||||||
nostr.setRelaylist(relays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
return nostr.getMsgRelays(nostr.signer.currentUser!!)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addMsgRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
|
||||||
relays.add(relayUrl)
|
|
||||||
|
|
||||||
nostr.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun removeMsgRelay(relay: String) {
|
|
||||||
try {
|
|
||||||
val relayUrl = RelayUrl.parse(relay)
|
|
||||||
val relays = currentUserMsgRelayList().toMutableSet()
|
|
||||||
relays.remove(relayUrl)
|
|
||||||
|
|
||||||
nostr.setMsgRelays(relays.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
|
||||||
try {
|
|
||||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
|
||||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
|
||||||
|
|
||||||
val currentUser = nostr.signer.currentUser!!
|
|
||||||
|
|
||||||
// Construct the rumor event
|
|
||||||
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
|
||||||
.finalizeUnsigned(currentUser)
|
|
||||||
|
|
||||||
// Check if the room already exists
|
|
||||||
val id = rumor.roomId()
|
|
||||||
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
|
|
||||||
// If the room already exists, return its ID
|
|
||||||
if (existingRoom != null) {
|
|
||||||
return existingRoom.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a room from the rumor event
|
|
||||||
val room = Room.new(rumor, currentUser)
|
|
||||||
|
|
||||||
// Update the chat rooms state
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
(currentRooms + room).sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
return room.id
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room? {
|
|
||||||
return chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mergeChatRooms(rooms: Set<Room>) {
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
|
||||||
// Add or update rooms from the database
|
|
||||||
rooms.forEach { room ->
|
|
||||||
merged[room.id] = room
|
|
||||||
}
|
|
||||||
// Return as a sorted set to maintain UI consistency
|
|
||||||
merged.values.sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRooms() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
|
||||||
try {
|
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
|
||||||
mergeChatRooms(rooms)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
return nostr.getChatRoomMessages(roomId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun chatRoomConnect(roomId: Long) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
val members = room.members
|
|
||||||
|
|
||||||
nostr.chatRoomConnect(members.toList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
|
||||||
if (message.isEmpty()) {
|
|
||||||
showError("Message cannot be empty")
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
|
||||||
nostr.sendMessage(
|
|
||||||
to = room.members,
|
|
||||||
content = message,
|
|
||||||
subject = room.subject,
|
|
||||||
replies = replies,
|
|
||||||
onRumorCreated = { event ->
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
viewModelScope.launch { _newEvents.emit(event) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isMessageSent(id: EventId): Boolean {
|
|
||||||
val giftWrapId = nostr.rumorMap[id]
|
|
||||||
|
|
||||||
if (giftWrapId != null) {
|
|
||||||
val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
|
||||||
return isSent
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
currentRooms.map { room ->
|
|
||||||
if (room.id == roomId) {
|
|
||||||
room.copy(
|
|
||||||
lastMessage = newMessage.content(),
|
|
||||||
createdAt = newMessage.createdAt()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
room
|
|
||||||
}
|
|
||||||
}.sortedDescending().toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
return nostr.searchByAddress(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
return nostr.searchByNostr(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PublicKey.short(): String {
|
|
||||||
val bech32 = toBech32()
|
|
||||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
|
||||||
}
|
|
||||||
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
|
||||||
|
data class Profile(
|
||||||
|
val publicKey: PublicKey,
|
||||||
|
val metadata: Metadata
|
||||||
|
) {
|
||||||
|
private val record by lazy { metadata.asRecord() }
|
||||||
|
|
||||||
|
val name: String
|
||||||
|
get() = record.displayName ?: record.name ?: publicKey.short()
|
||||||
|
|
||||||
|
val picture: String?
|
||||||
|
get() = record.picture
|
||||||
|
|
||||||
|
val shortPublicKey: String
|
||||||
|
get() = publicKey.short()
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
@@ -8,6 +15,7 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -75,9 +83,60 @@ data class Room(
|
|||||||
return this.copy(lastMessage = message)
|
return this.copy(lastMessage = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isGroup(): Boolean {
|
fun isGroup(): Boolean = members.size > 1
|
||||||
return members.size > 1
|
}
|
||||||
|
|
||||||
|
data class RoomUiState(
|
||||||
|
val name: String = "Loading...",
|
||||||
|
val picture: String? = null,
|
||||||
|
val isGroup: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Room.uiStateFlow(
|
||||||
|
nostrViewModel: NostrViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): Flow<RoomUiState> {
|
||||||
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
|
|
||||||
|
if (!subject.isNullOrBlank()) {
|
||||||
|
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
|
||||||
|
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
|
||||||
|
|
||||||
|
val name = when {
|
||||||
|
isGroup() -> {
|
||||||
|
val combined = names.joinToString(", ")
|
||||||
|
val extra = members.size - names.size
|
||||||
|
if (extra > 0) "$combined, +$extra" else combined
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val first = names.firstOrNull() ?: "Unknown"
|
||||||
|
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomUiState(
|
||||||
|
name = name,
|
||||||
|
picture = profiles.firstOrNull()?.picture,
|
||||||
|
isGroup = isGroup()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Room.rememberUiState(
|
||||||
|
viewModel: NostrViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): State<RoomUiState> {
|
||||||
|
return remember(this, currentUser) {
|
||||||
|
uiStateFlow(
|
||||||
|
viewModel,
|
||||||
|
currentUser
|
||||||
|
)
|
||||||
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun UnsignedEvent.roomId(): Long {
|
fun UnsignedEvent.roomId(): Long {
|
||||||
@@ -94,21 +153,25 @@ fun UnsignedEvent.roomId(): Long {
|
|||||||
return sortedUniqueKeys.hashCode().toLong()
|
return sortedUniqueKeys.hashCode().toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Timestamp.ago(): String {
|
fun Timestamp.formatAsTime(): String {
|
||||||
val SECONDS_IN_MINUTE = 60L
|
val timeZone = TimeZone.currentSystemDefault()
|
||||||
val MINUTES_IN_HOUR = 60L
|
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val HOURS_IN_DAY = 24L
|
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
|
||||||
val DAYS_IN_MONTH = 30L
|
val hour = inputDateTime.hour.toString().padStart(2, '0')
|
||||||
|
val minute = inputDateTime.minute.toString().padStart(2, '0')
|
||||||
|
return "$hour:$minute"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Timestamp.ago(): String {
|
||||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
val duration = now - inputInstant
|
val duration = now - inputInstant
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
duration.inWholeSeconds < 60L -> "Now"
|
||||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m"
|
||||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
duration.inWholeHours < 24L -> "${duration.inWholeHours}h"
|
||||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
duration.inWholeDays < 30L -> "${duration.inWholeDays}d"
|
||||||
else -> {
|
else -> {
|
||||||
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
val month =
|
val month =
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.Event
|
import rust.nostr.sdk.Event
|
||||||
341
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
341
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Alphabet
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SingleLetterTag
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
data class MessageSyncState(
|
||||||
|
val processedCount: Int = 0,
|
||||||
|
val isSyncing: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class MessageManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
|
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
|
|
||||||
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||||
|
_messageSyncState.update(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
|
try {
|
||||||
|
val author =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
|
// Ensure relay connections
|
||||||
|
relays.forEach { relay ->
|
||||||
|
client?.addRelay(relay)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a filter for gift wrap events
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
relays.forEach { relay ->
|
||||||
|
target[relay] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "gift-wraps"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
// Gift wrap must have at least one 'p' tag
|
||||||
|
if (event.tags().publicKeys().isEmpty()) {
|
||||||
|
println("No recipient tags found.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event must be a gift wrap
|
||||||
|
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
|
||||||
|
println("Event is not a gift wrap.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the rumor is already cached
|
||||||
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
|
// Decrypt the gift wrap event
|
||||||
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
|
// Verify seal event
|
||||||
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the rumor
|
||||||
|
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||||
|
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
|
||||||
|
|
||||||
|
// Ensure the rumor author matches the seal
|
||||||
|
if (unsignedEvent.author() != sealEvent.author()) {
|
||||||
|
println("Author mismatch.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the rumor for later use
|
||||||
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
|
return unsignedEvent
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
|
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
|
try {
|
||||||
|
// Construct reference tags
|
||||||
|
val tags = listOf(
|
||||||
|
Tag.identifier(giftId.toHex()),
|
||||||
|
Tag.publicKey(rumor.author()),
|
||||||
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set event kind
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
|
// Construct event
|
||||||
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
|
client?.database()?.saveEvent(event)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
|
try {
|
||||||
|
val userPubkey =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
|
// Get all DM events
|
||||||
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Collect rooms
|
||||||
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
|
events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.forEach { event ->
|
||||||
|
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
||||||
|
val existingRoom = roomsMap[newRoom.id]
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
|
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||||
|
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||||
|
.customTag(rTag, newRoom.id.toString())
|
||||||
|
|
||||||
|
// Determine if it's an ongoing room
|
||||||
|
val isOngoing =
|
||||||
|
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||||
|
|
||||||
|
// Append room to map
|
||||||
|
roomsMap[newRoom.id] =
|
||||||
|
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get chat rooms: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val filter = Filter().kind(kind).reference(roomId.toString())
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Merge the events
|
||||||
|
return events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||||
|
// Filter out events without public keys (receivers)
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
members.forEach { member ->
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = ReqTarget.auto(listOf(filter)),
|
||||||
|
id = null,
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
stream?.next()?.let { res ->
|
||||||
|
val event = res.event ?: return@let
|
||||||
|
connectMsgRelays(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
|
try {
|
||||||
|
val urls = nip17ExtractRelayList(event)
|
||||||
|
for (url in urls) {
|
||||||
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
|
client?.connectRelay(url)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(
|
||||||
|
to: Set<PublicKey>,
|
||||||
|
content: String,
|
||||||
|
subject: String? = null,
|
||||||
|
replies: List<EventId> = emptyList(),
|
||||||
|
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val tags = mutableListOf<Tag>()
|
||||||
|
|
||||||
|
// Add a subject tag if provided
|
||||||
|
if (subject != null) {
|
||||||
|
tags.add(Tag.custom("subject", listOf(subject)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event tags for replies
|
||||||
|
if (replies.isNotEmpty()) {
|
||||||
|
replies.forEach { replyId ->
|
||||||
|
tags.add(Tag.event(replyId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public key tags for each recipient
|
||||||
|
to.forEach { pubkey ->
|
||||||
|
tags.add(Tag.publicKey(pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (receiver in setOf(currentUser) + to) {
|
||||||
|
// Construct the rumor event
|
||||||
|
// NEVER SIGN this event with the current user signer
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
.ensureId()
|
||||||
|
|
||||||
|
// Emit the rumor to the chat screen
|
||||||
|
if (receiver == currentUser) {
|
||||||
|
onRumorCreated?.invoke(rumor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the gift wrap event
|
||||||
|
val gift = nip59MakeGiftWrapAsync(
|
||||||
|
signer = signer,
|
||||||
|
receiverPubkey = receiver,
|
||||||
|
rumor = rumor,
|
||||||
|
extraTags = listOf(
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send the event to receiver's NIP-17 relays
|
||||||
|
val output = client?.sendEvent(
|
||||||
|
event = gift,
|
||||||
|
target = SendEventTarget.toNip17(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
authenticationTimeout = Duration.parse("2s")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (output != null) {
|
||||||
|
// Keep track of sent events
|
||||||
|
sentEvents[output.id] = emptyList()
|
||||||
|
|
||||||
|
// Keep track of rumor IDs
|
||||||
|
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||||
|
rumorMap[id] = output.id
|
||||||
|
|
||||||
|
// Collect failed outputs
|
||||||
|
output.failed.forEach { (relayUrl, reason) ->
|
||||||
|
println("Failed to send event to relay $relayUrl: $reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
267
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
267
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.ClientBuilder
|
||||||
|
import rust.nostr.sdk.ClientNotification
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.GossipConfig
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.LogLevel
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.NostrDatabase
|
||||||
|
import rust.nostr.sdk.NostrGossip
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
|
import rust.nostr.sdk.SignerAuthenticator
|
||||||
|
import rust.nostr.sdk.SleepWhenIdle
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.initLogger
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
object NostrManager {
|
||||||
|
val instance = Nostr()
|
||||||
|
|
||||||
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
)
|
||||||
|
|
||||||
|
val INDEXER_RELAY = listOf(
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
class Nostr {
|
||||||
|
var client: Client? = null
|
||||||
|
private set
|
||||||
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
|
private set
|
||||||
|
|
||||||
|
val messages = MessageManager(this)
|
||||||
|
val profiles = ProfileManager(this)
|
||||||
|
val relays = RelayManager(this)
|
||||||
|
|
||||||
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun init(
|
||||||
|
dbPath: String,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (isInitialized.value) return
|
||||||
|
|
||||||
|
// Initialize the logger for nostr client
|
||||||
|
initLogger(logLevel)
|
||||||
|
|
||||||
|
// Initialize configurations for nostr client
|
||||||
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
|
val gossip = NostrGossip.inMemory()
|
||||||
|
|
||||||
|
// Initialize the authenticator
|
||||||
|
val authenticator = SignerAuthenticator(signer)
|
||||||
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
|
client =
|
||||||
|
ClientBuilder()
|
||||||
|
.authenticator(authenticator)
|
||||||
|
.database(lmdb)
|
||||||
|
.gossip(gossip)
|
||||||
|
.gossipConfig(
|
||||||
|
GossipConfig()
|
||||||
|
.noBackgroundRefresh()
|
||||||
|
.fetchTimeout(Duration.parse("2s"))
|
||||||
|
)
|
||||||
|
.verifySubscriptions(false)
|
||||||
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
isInitialized.value = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun waitUntilInitialized() {
|
||||||
|
isInitialized.first { it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
relays.connectBootstrapRelays()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
relays.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
relays.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun prune() {
|
||||||
|
try {
|
||||||
|
client?.database()?.wipe()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
|
try {
|
||||||
|
signer.switch(new)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
|
return try {
|
||||||
|
signer.publicKeyFlow.value == event.author()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun handleNotifications(
|
||||||
|
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||||
|
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||||
|
onNewMessage: (UnsignedEvent) -> Unit,
|
||||||
|
) = supervisorScope {
|
||||||
|
val now = Timestamp.now()
|
||||||
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
|
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||||
|
var processedCount = 0
|
||||||
|
var eoseReceived = false
|
||||||
|
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
for (event in giftWrapQueue) {
|
||||||
|
val rumor = messages.extractRumor(event)
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
// Trigger new message notification
|
||||||
|
if (rumor != null) {
|
||||||
|
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||||
|
onNewMessage(rumor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
messages.updateSyncState {
|
||||||
|
it.copy(
|
||||||
|
processedCount = processedCount,
|
||||||
|
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val notification = notifications.next() ?: continue
|
||||||
|
|
||||||
|
when (notification) {
|
||||||
|
is ClientNotification.Message -> {
|
||||||
|
val relayUrl = notification.relayUrl
|
||||||
|
|
||||||
|
when (val message = notification.message.asEnum()) {
|
||||||
|
is RelayMessageEnum.EventMsg -> {
|
||||||
|
val event = message.event
|
||||||
|
|
||||||
|
// Prevent processing duplicate events
|
||||||
|
if (processedEvent.contains(event.id())) continue
|
||||||
|
processedEvent.add(event.id())
|
||||||
|
|
||||||
|
when (event.kind().asStd()) {
|
||||||
|
KindStandard.METADATA -> {
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
onMetadataUpdate(event.author(), metadata)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.CONTACT_LIST -> {
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
val pubkeys = event.tags().publicKeys()
|
||||||
|
// Get mutual contacts
|
||||||
|
profiles.syncMutualContacts(pubkeys)
|
||||||
|
// Emit contact list update
|
||||||
|
onContactListUpdate(pubkeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.INBOX_RELAYS -> {
|
||||||
|
// Get all gift wrap events for the current user
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
messages.getUserMessages(msgRelayList = event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.GIFT_WRAP -> {
|
||||||
|
giftWrapQueue.send(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
|
if (message.subscriptionId == "gift-wraps") {
|
||||||
|
eoseReceived = true
|
||||||
|
if (giftWrapQueue.isEmpty) {
|
||||||
|
messages.updateSyncState { it.copy(isSyncing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.Ok -> {
|
||||||
|
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||||
|
val currentRelays =
|
||||||
|
messages.sentEvents[message.eventId] ?: emptyList()
|
||||||
|
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientNotification.Shutdown -> {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Contact
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.MetadataRecord
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.Nip05Profile
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class ProfileManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
private val _metadataUpdates =
|
||||||
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
||||||
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||||
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
|
_metadataUpdates.emit(pubkey to metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||||
|
_contactListUpdates.emit(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMetadata() {
|
||||||
|
try {
|
||||||
|
val author =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Get the latest metadata event
|
||||||
|
val metadataFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest contact list event
|
||||||
|
val contactFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest messaging relay list event
|
||||||
|
val msgRelayFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Construct a target that includes all filters
|
||||||
|
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
|
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||||
|
|
||||||
|
client?.sync(filter, relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
|
// Send relay list event
|
||||||
|
val relayList = nostr.relays.getDefaultRelayList()
|
||||||
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = relayListEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.all(),
|
||||||
|
okTimeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send messaging relay list event
|
||||||
|
val msgRelayList = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = msgRelayListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send metadata event
|
||||||
|
val metadata =
|
||||||
|
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
||||||
|
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = metadataEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send contact list event
|
||||||
|
val defaultContact =
|
||||||
|
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||||
|
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = contactListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
nostr.setSigner(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: String? = null
|
||||||
|
): Metadata {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
|
val newRecord = record.copy(
|
||||||
|
displayName = name ?: record.displayName,
|
||||||
|
about = bio ?: record.about,
|
||||||
|
picture = picture ?: record.picture
|
||||||
|
)
|
||||||
|
val newMetadata = Metadata.fromRecord(newRecord)
|
||||||
|
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
return newMetadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
|
return try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
|
Metadata.fromJson(event.content())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get latest metadata: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||||
|
try {
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val results = mutableMapOf<PublicKey, Metadata>()
|
||||||
|
|
||||||
|
events?.toVec()?.forEach { event ->
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
results[event.author()] = metadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get all cache metadata: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val limit = keys.size.toULong() * 2u
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
// Construct a filter for metadata events
|
||||||
|
val filter = Filter()
|
||||||
|
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
.authors(keys)
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
// Construct request target
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val contactList = contacts.map { Contact(it) }
|
||||||
|
val event = EventBuilder.contactList(contactList).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 profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
||||||
|
try {
|
||||||
|
val response: HttpResponse = client.get(address.url())
|
||||||
|
val bodyString: String = response.body()
|
||||||
|
|
||||||
|
return Nip05Profile.fromJson(address, bodyString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey {
|
||||||
|
try {
|
||||||
|
val address = Nip05Address.parse(query)
|
||||||
|
val profile = profileFromAddress(HttpClient(), address)
|
||||||
|
|
||||||
|
return profile.publicKey()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
// Add search relay
|
||||||
|
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
|
||||||
|
if (client?.relay(searchRelay) == null) {
|
||||||
|
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
|
||||||
|
client?.connectRelay(searchRelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
||||||
|
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = target,
|
||||||
|
id = "search",
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect the results
|
||||||
|
val results = mutableListOf<PublicKey>()
|
||||||
|
|
||||||
|
// Keep searching until the stream is closed or timeout
|
||||||
|
stream?.next()?.let { event ->
|
||||||
|
val event = event.event ?: return@let
|
||||||
|
results.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
183
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayStatus
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.extractRelayList
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class RelayManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
|
}
|
||||||
|
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = RelayUrl.parse(url),
|
||||||
|
capabilities = RelayCapabilities.gossip()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Connect to all bootstrap relays
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||||
|
if (relay != null) {
|
||||||
|
if (relay.status() != RelayStatus.CONNECTED) {
|
||||||
|
relay.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to reconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.disconnectRelay(RelayUrl.parse(url))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to disconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
|
// Construct a list of relays
|
||||||
|
val relayList = mapOf(
|
||||||
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
|
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
relayList.forEach { (relay, metadata) ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = relay,
|
||||||
|
capabilities =
|
||||||
|
when (metadata) {
|
||||||
|
RelayMetadata.READ -> RelayCapabilities.read()
|
||||||
|
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relayList
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||||
|
// Construct a list of messaging relays
|
||||||
|
val msgRelayList = listOf(
|
||||||
|
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||||
|
RelayUrl.parse("wss://nip17.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
msgRelayList.forEach { relay ->
|
||||||
|
client?.addRelay(relay, RelayCapabilities.none())
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgRelayList
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val event = events?.first() ?: return emptyList()
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
@@ -16,9 +18,8 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var signer: AsyncNostrSigner = initialSigner
|
private var signer: AsyncNostrSigner = initialSigner
|
||||||
|
|
||||||
@Volatile
|
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
|
||||||
var currentUser: PublicKey? = null
|
val publicKeyFlow = _publicKeyFlow.asStateFlow()
|
||||||
private set
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current signer.
|
* Get the current signer.
|
||||||
@@ -37,7 +38,7 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
|||||||
throw IllegalStateException("Failed to get public key from signer", e)
|
throw IllegalStateException("Failed to get public key from signer", e)
|
||||||
}
|
}
|
||||||
signer = newSigner
|
signer = newSigner
|
||||||
currentUser = pubkey
|
_publicKeyFlow.value = pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
|
||||||
|
object ErrorRepository {
|
||||||
|
private val _errors = Channel<String>(Channel.BUFFERED)
|
||||||
|
val errors = _errors.receiveAsFlow()
|
||||||
|
|
||||||
|
fun showError(message: String) {
|
||||||
|
_errors.trySend(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
|
||||||
|
class MediaRepository {
|
||||||
|
private val httpClient = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun blossomUpload(
|
||||||
|
signer: AsyncNostrSigner,
|
||||||
|
file: ByteArray,
|
||||||
|
contentType: String? = "image/jpeg"
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||||
|
val descriptor = blossom.upload(
|
||||||
|
file = file,
|
||||||
|
contentType = contentType,
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
|
descriptor?.url
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Upload failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.NostrConnect
|
||||||
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.storage.SecretStorage
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class AuthState(
|
||||||
|
val isBusy: Boolean = false,
|
||||||
|
val signerRequired: Boolean? = null,
|
||||||
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val secretStore: SecretStorage,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_USER_SIGNER = "user_signer"
|
||||||
|
private const val KEY_APP_KEYS = "app_keys"
|
||||||
|
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AuthState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Check if the notification banner has been dismissed
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
|
||||||
|
// Check local stored secret (secret key or bunker)
|
||||||
|
login()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val secret = withTimeoutOrNull(3.seconds) {
|
||||||
|
secretStore.get(KEY_USER_SIGNER)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret == null) {
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
}.onSuccess {
|
||||||
|
_state.update { it.copy(signerRequired = false) }
|
||||||
|
}.onFailure { e ->
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onLogout: () -> Unit = {}) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
|
// Reset the nostr signer and prune the database
|
||||||
|
nostr.signer.switch(Keys.generate())
|
||||||
|
nostr.prune()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Logout encountered an error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
// Clear credentials from persistent storage
|
||||||
|
secretStore.clear(KEY_USER_SIGNER)
|
||||||
|
secretStore.clear(KEY_BANNER_DISMISSED)
|
||||||
|
|
||||||
|
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||||
|
onLogout()
|
||||||
|
|
||||||
|
_state.update { it.copy(isBusy = false, signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
|
val secret = secretStore.get(KEY_APP_KEYS)
|
||||||
|
|
||||||
|
// If app keys are already stored, use them
|
||||||
|
if (secret != null) {
|
||||||
|
return Keys.parse(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new app keys and save to the secret storage
|
||||||
|
val keys = Keys.generate()
|
||||||
|
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||||
|
return when {
|
||||||
|
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||||
|
|
||||||
|
secret.startsWith("bunker://") -> {
|
||||||
|
val appKeys = getOrInitAppKeys()
|
||||||
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
|
val timeout = 50.seconds
|
||||||
|
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.startsWith("nip55://") -> {
|
||||||
|
val handler = externalSignerHandler
|
||||||
|
?: throw IllegalStateException("External signer not available on this platform")
|
||||||
|
|
||||||
|
// Format: nip55://packageName/hexPubkey
|
||||||
|
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||||
|
val packageName = parts[0]
|
||||||
|
val pubkey = PublicKey.parse(parts[1])
|
||||||
|
|
||||||
|
handler.setPackageName(packageName)
|
||||||
|
ExternalSignerProxy(handler, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
if (secret.startsWith("bunker://")) {
|
||||||
|
showError("Please approve the connection.")
|
||||||
|
}
|
||||||
|
return signer.getPublicKeyAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importIdentity(secret: String) {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
// Update signer
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set(KEY_USER_SIGNER, secret)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectExternalSigner() {
|
||||||
|
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val permissions = SignerPermissions.toJson(
|
||||||
|
listOf(
|
||||||
|
SignerPermissions.signEvent(0),
|
||||||
|
SignerPermissions.signEvent(3),
|
||||||
|
SignerPermissions.signEvent(10000),
|
||||||
|
SignerPermissions.signEvent(10050),
|
||||||
|
SignerPermissions.signEvent(10063),
|
||||||
|
SignerPermissions.signEvent(22242),
|
||||||
|
SignerPermissions.signEvent(30030),
|
||||||
|
SignerPermissions.signEvent(30315),
|
||||||
|
SignerPermissions.nip44Encrypt(),
|
||||||
|
SignerPermissions.nip44Decrypt(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||||
|
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||||
|
|
||||||
|
// Update signer
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Store the signer in the secret storage
|
||||||
|
secretStore.set(
|
||||||
|
KEY_USER_SIGNER,
|
||||||
|
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||||
|
)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
showError("Notice: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
|
return externalSignerHandler?.isAvailable() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create identity
|
||||||
|
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
||||||
|
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set(KEY_USER_SIGNER, secret)
|
||||||
|
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(isBusy = false, signerRequired = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import su.reya.coop.repository.ErrorRepository
|
||||||
|
|
||||||
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
protected fun showError(message: String) {
|
||||||
|
ErrorRepository.showError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
|
||||||
|
data class ChatState(
|
||||||
|
val rooms: Set<Room> = emptySet(),
|
||||||
|
val isSyncing: Boolean = false,
|
||||||
|
val isPartialProcessedGiftWrap: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatState())
|
||||||
|
val state = combine(
|
||||||
|
_state,
|
||||||
|
nostr.messages.messageSyncState
|
||||||
|
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
ChatState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||||
|
val sentReport = _sentReports.asSharedFlow()
|
||||||
|
|
||||||
|
val chatRooms = state.map { it.rooms }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||||
|
|
||||||
|
val isSyncing = state.map { it.isSyncing }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Observe message sync progress
|
||||||
|
launch {
|
||||||
|
nostr.messages.messageSyncState.collect { syncState ->
|
||||||
|
// When at least some messages are processed, allow UI to show the list
|
||||||
|
if (syncState.processedCount > 0) {
|
||||||
|
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI every 10 messages OR when sync is fully done
|
||||||
|
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe new messages
|
||||||
|
launch {
|
||||||
|
nostr.newEvents.collect { event ->
|
||||||
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
|
||||||
|
|
||||||
|
if (existingRoom == null) {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||||
|
val newRoom = Room.new(event, currentUser)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = (it.rooms + newRoom).sortedDescending().toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load of rooms
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
|
if (to.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.value
|
||||||
|
?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Construct the rumor event
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||||
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
val id = rumor.roomId()
|
||||||
|
val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
// If the room already exists, return its ID
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return existingRoom.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a room from the rumor event
|
||||||
|
val room = Room.new(rumor, currentUser)
|
||||||
|
|
||||||
|
// Update the chat rooms state
|
||||||
|
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
|
||||||
|
|
||||||
|
return room.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRoom(id: Long): Room? {
|
||||||
|
return _state.value.rooms.firstOrNull { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshChatRooms() {
|
||||||
|
try {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
_state.update { currentState ->
|
||||||
|
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
|
||||||
|
// Add or update rooms from the database
|
||||||
|
rooms.forEach { room ->
|
||||||
|
merged[room.id] = room
|
||||||
|
}
|
||||||
|
// Return as a sorted set to maintain UI consistency
|
||||||
|
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
return nostr.messages.getChatRoomMessages(roomId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRoomConnect(roomId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
val members = room.members
|
||||||
|
|
||||||
|
nostr.messages.chatRoomConnect(members.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
showError("Message cannot be empty")
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
nostr.messages.sendMessage(
|
||||||
|
to = room.members,
|
||||||
|
content = message,
|
||||||
|
subject = room.subject,
|
||||||
|
replies = replies,
|
||||||
|
onRumorCreated = { event ->
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
viewModelScope.launch { _newEvents.emit(event) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendFileMessage(
|
||||||
|
roomId: Long,
|
||||||
|
file: ByteArray?,
|
||||||
|
contentType: String? = "image/jpeg",
|
||||||
|
replies: List<EventId> = emptyList()
|
||||||
|
) {
|
||||||
|
if (file == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
||||||
|
if (uri != null) sendMessage(roomId, uri, replies)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
|
||||||
|
if (giftWrapId != null) {
|
||||||
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
|
return isSent
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedRooms = currentState.rooms.map { room ->
|
||||||
|
if (room.id == roomId) {
|
||||||
|
room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
|
createdAt = newMessage.createdAt()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
room
|
||||||
|
}
|
||||||
|
}.sortedDescending().toSet()
|
||||||
|
currentState.copy(rooms = updatedRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = emptySet(),
|
||||||
|
isPartialProcessedGiftWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.Profile
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class NostrAppState(
|
||||||
|
val isBusy: Boolean = false,
|
||||||
|
val isRelayListEmpty: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
private val alwaysRunTasks = flow {
|
||||||
|
coroutineScope {
|
||||||
|
val observerJob = launch { runObserver() }
|
||||||
|
val batchingJob = launch { runMetadataBatching() }
|
||||||
|
try {
|
||||||
|
emit(Unit)
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
observerJob.cancel()
|
||||||
|
batchingJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _appState = MutableStateFlow(NostrAppState())
|
||||||
|
val appState: StateFlow<NostrAppState> =
|
||||||
|
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = NostrAppState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val profilesMutex = Mutex()
|
||||||
|
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||||
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
val isBusy = appState.map { it.isBusy }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val currentUserProfile = nostr.signer.publicKeyFlow
|
||||||
|
.flatMapLatest { pubkey ->
|
||||||
|
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Automatically reconnect bootstrap relays
|
||||||
|
reconnect()
|
||||||
|
|
||||||
|
// Observe the signer state and verify the relay list
|
||||||
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
|
// Get all local stored metadata
|
||||||
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runObserver() = coroutineScope {
|
||||||
|
// Observe contact list updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe metadata updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
val batch = mutableSetOf<PublicKey>()
|
||||||
|
val timeout = 500L // 500ms timeout for batching
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Get the first pubkey
|
||||||
|
val firstKey = metadataRequestChannel.receive()
|
||||||
|
batch.add(firstKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
while (batch.isNotEmpty()) {
|
||||||
|
// Get the next pubkey
|
||||||
|
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||||
|
metadataRequestChannel.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add the pubkey if it's not null
|
||||||
|
if (nextKey != null) batch.add(nextKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
// Check if the batch is full or timeout has passed
|
||||||
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
|
val keysToRequest = batch.toList()
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCacheMetadata() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
|
||||||
|
// Update the metadata state
|
||||||
|
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||||
|
// Update seenPublicKeys to avoid duplicate requests
|
||||||
|
seenPublicKeys.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignerAndCheckRelays() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Wait until a signer is explicitly set (which updates publicKeyFlow)
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
||||||
|
|
||||||
|
// Get all metadata for the current user
|
||||||
|
nostr.profiles.getUserMetadata()
|
||||||
|
|
||||||
|
// Small delay to ensure all relays are connected
|
||||||
|
delay(2.seconds)
|
||||||
|
|
||||||
|
// Check if the relay list is empty
|
||||||
|
val relays = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
metadataRequestChannel.send(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
profilesMutex.withLock {
|
||||||
|
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||||
|
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||||
|
if (flow.value == null) requestMetadata(pubkey)
|
||||||
|
|
||||||
|
return flow.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
_appState.update {
|
||||||
|
it.copy(
|
||||||
|
isRelayListEmpty = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_appState.update { it.copy(isRelayListEmpty = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_appState.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val avatarUrl =
|
||||||
|
picture?.let {
|
||||||
|
mediaRepository.blossomUpload(
|
||||||
|
nostr.signer.get(),
|
||||||
|
it,
|
||||||
|
contentType ?: "image/jpeg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
|
||||||
|
// Update the metadata state after successfully published
|
||||||
|
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
_appState.update { it.copy(isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refetchMsgRelays() {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||||
|
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun useDefaultMsgRelayList() {
|
||||||
|
try {
|
||||||
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getRelayList(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addInboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addOutboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.add(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun newContact(publicKey: PublicKey) {
|
||||||
|
if (publicKey in contactList.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value + publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.profiles.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.profiles.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.profiles.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByAddress(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByNostr(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyActivity(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyContact(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.mutualContacts(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
setOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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