Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bfe2308ba | |||
| eed9d401da | |||
| 3943e2baab | |||
| d5ea5570b6 | |||
| 26f9e577f4 | |||
| dd416711e2 | |||
| b90279e174 | |||
| 25ba03562a | |||
| 010a8b4060 | |||
| 750d46bc9e | |||
| 790d24c24b | |||
| 4fbc8446d3 | |||
| b4518ce360 | |||
| 0f4bb71a39 | |||
| 37003e2fc4 | |||
| 154906017e | |||
| 91e4e3b43d | |||
| ea90a43909 | |||
| 938b192136 | |||
| 1f966de654 | |||
| 00821a864b | |||
| 28550f8e25 | |||
| a759ad48e4 | |||
| 0d6b92b0c7 | |||
| 6a69d3a5b2 | |||
| 50b7f7a3f3 | |||
| a65aa70a55 | |||
| 74a37320fe | |||
| b8b3b83952 | |||
| 5c2115e8b7 | |||
| ec337b8756 | |||
| fcae7d5825 | |||
| 1e90b8d4b1 | |||
| 71a8240b1d | |||
| ff383a7c6a | |||
| 15e8c984e2 | |||
| 0da1371345 | |||
| b7c5b64022 | |||
| a3ab489d44 |
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 |
@@ -19,12 +19,14 @@ kotlin {
|
|||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
|
||||||
implementation(libs.androidx.lifecycle.process)
|
implementation(libs.androidx.lifecycle.process)
|
||||||
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
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.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
@@ -67,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.3"
|
versionName = "0.2.2"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -8,9 +8,17 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="nostrsigner" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -19,6 +27,12 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".CrashActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":crash_handler"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -45,7 +59,7 @@
|
|||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="remoteMessaging" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:autoMirrored="true"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="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,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerResult
|
||||||
|
|
||||||
|
class AndroidExternalSigner(
|
||||||
|
private val context: Context,
|
||||||
|
private val launcher: ExternalSignerLauncher,
|
||||||
|
) : ExternalSignerHandler {
|
||||||
|
private var cachedPackageName: String? = null
|
||||||
|
|
||||||
|
private data class ContentResolverResult(
|
||||||
|
val result: String,
|
||||||
|
val event: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun queryContentResolver(
|
||||||
|
type: String,
|
||||||
|
payload: String,
|
||||||
|
pubkey: PublicKey? = null,
|
||||||
|
currentUser: PublicKey? = null,
|
||||||
|
): ContentResolverResult? {
|
||||||
|
val uri = "content://$cachedPackageName.${type.uppercase()}".toUri()
|
||||||
|
val projection = mutableListOf<String?>().apply {
|
||||||
|
add(payload)
|
||||||
|
add(pubkey?.toHex() ?: "")
|
||||||
|
add(currentUser?.toHex() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
projection.toTypedArray(),
|
||||||
|
null, null, null,
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
return cursor.use {
|
||||||
|
if (it.getColumnIndex("rejected") > -1) return null
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
val resultIndex = it.getColumnIndex("result")
|
||||||
|
val result = if (resultIndex > -1) it.getString(resultIndex) else null
|
||||||
|
|
||||||
|
val eventIndex = it.getColumnIndex("event")
|
||||||
|
val event = if (eventIndex > -1) it.getString(eventIndex) else null
|
||||||
|
|
||||||
|
ContentResolverResult(result = result!!, event = event)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun request(
|
||||||
|
type: String,
|
||||||
|
payload: String,
|
||||||
|
pubkey: PublicKey? = null,
|
||||||
|
currentUser: PublicKey? = null,
|
||||||
|
resultKey: String = "result",
|
||||||
|
extras: Map<String, String> = emptyMap(),
|
||||||
|
): String? {
|
||||||
|
// Try Content Resolver first
|
||||||
|
queryContentResolver(type, payload, pubkey, currentUser)?.let {
|
||||||
|
return if (resultKey == "event") it.event else it.result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Intent
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
|
||||||
|
`package` = cachedPackageName
|
||||||
|
putExtra("type", type)
|
||||||
|
if (pubkey != null) putExtra("pubkey", pubkey.toHex())
|
||||||
|
if (currentUser != null) putExtra("current_user", currentUser.toHex())
|
||||||
|
extras.forEach { (k, v) -> putExtra(k, v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = launcher.launch(intent)
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return null
|
||||||
|
|
||||||
|
val data = result.data ?: return null
|
||||||
|
if (data.getBooleanExtra("rejected", false)) return null
|
||||||
|
|
||||||
|
return data.getStringExtra(resultKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAvailable(): Boolean {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
|
||||||
|
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPackageName(packageName: String) {
|
||||||
|
cachedPackageName = packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
|
||||||
|
putExtra("type", "get_public_key")
|
||||||
|
if (permissions != null) putExtra("permissions", permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = launcher.launch(intent)
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return null
|
||||||
|
|
||||||
|
val data = result.data ?: return null
|
||||||
|
if (data.getBooleanExtra("rejected", false)) return null
|
||||||
|
|
||||||
|
val pubkey = data.getStringExtra("result") ?: return null
|
||||||
|
val packageName = data.getStringExtra("package") ?: return null
|
||||||
|
cachedPackageName = packageName
|
||||||
|
|
||||||
|
return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
|
||||||
|
val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
|
||||||
|
return request(
|
||||||
|
type = "sign_event",
|
||||||
|
payload = event.asJson(),
|
||||||
|
currentUser = currentUser,
|
||||||
|
resultKey = "event",
|
||||||
|
extras = extras,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
|
||||||
|
return request("nip04_encrypt", plaintext, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
|
||||||
|
return request("nip04_decrypt", ciphertext, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44Encrypt(
|
||||||
|
plaintext: String,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
currentUser: PublicKey
|
||||||
|
): String? {
|
||||||
|
return request("nip44_encrypt", plaintext, pubkey, currentUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44Decrypt(
|
||||||
|
ciphertext: String,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
currentUser: PublicKey
|
||||||
|
): String? {
|
||||||
|
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,52 +1,41 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.MotionScheme
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.expressiveLightColorScheme
|
import androidx.compose.material3.expressiveLightColorScheme
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.core.util.Consumer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation.navDeepLink
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import androidx.navigation.toRoute
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
import su.reya.coop.screens.MyQrScreen
|
import su.reya.coop.screens.MyQrScreen
|
||||||
@@ -55,36 +44,78 @@ 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.viewmodels.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodels.AppViewModel
|
||||||
|
import su.reya.coop.viewmodels.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodels.ProfileViewModel
|
||||||
|
import su.reya.coop.viewmodels.RelayViewModel
|
||||||
|
|
||||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
val LocalAppViewModel = staticCompositionLocalOf<AppViewModel> {
|
||||||
error("No NostrViewModel provided")
|
error("No AppViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalAccountViewModel = staticCompositionLocalOf<AccountViewModel> {
|
||||||
|
error("No AccountViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
||||||
|
error("No ChatViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalProfileViewModel = staticCompositionLocalOf<ProfileViewModel> {
|
||||||
|
error("No ProfileViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalRelayViewModel = staticCompositionLocalOf<RelayViewModel> {
|
||||||
|
error("No RelayViewModel provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
val LocalNavigator = staticCompositionLocalOf<Navigator> {
|
||||||
error("No NavController provided")
|
error("No Navigator provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||||
|
error("No QrScanResult provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(viewModel: NostrViewModel) {
|
fun App(
|
||||||
|
appViewModel: AppViewModel,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
profileViewModel: ProfileViewModel,
|
||||||
|
relayViewModel: RelayViewModel,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navController = rememberNavController()
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val darkMode = isSystemInDarkTheme()
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
|
// Get the signer required state
|
||||||
|
val signerRequired by accountViewModel.signerRequired.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
// Check if dark theme enabled
|
||||||
|
val darkMode = isSystemInDarkTheme()
|
||||||
|
|
||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
// Enable the dynamic color scheme for Android 12+
|
// Enable the dynamic color scheme for Android 12+
|
||||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||||
|
context
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// When dark mode is enabled, use the dark color scheme
|
// When dark mode is enabled, use the dark color scheme
|
||||||
darkMode -> darkColorScheme()
|
darkMode -> darkColorScheme()
|
||||||
@@ -92,178 +123,138 @@ fun App(viewModel: NostrViewModel) {
|
|||||||
else -> expressiveLightColorScheme()
|
else -> expressiveLightColorScheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = backStack.size > 1) {
|
||||||
|
navigator.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.errorEvents.collect { message ->
|
appViewModel.errorEvents.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activity) {
|
||||||
|
activity?.let {
|
||||||
|
fun handleIntent(intent: Intent) {
|
||||||
|
val screen = Screen.fromIntent(intent)
|
||||||
|
// Prevent pushing the same screen
|
||||||
|
if (screen != null && backStack.lastOrNull() != screen) {
|
||||||
|
navigator.navigate(screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the intent that started the Activity
|
||||||
|
handleIntent(it.intent)
|
||||||
|
|
||||||
|
// Handle new intents while the Activity is running
|
||||||
|
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
|
||||||
|
it.addOnNewIntentListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(backStack.size) {
|
||||||
|
if (backStack.isEmpty()) {
|
||||||
|
(context as? Activity)?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(signerRequired) {
|
||||||
|
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
|
||||||
|
backStack.clear()
|
||||||
|
backStack.add(Screen.Onboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MaterialExpressiveTheme(
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography(),
|
typography = Typography(),
|
||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNostrViewModel provides viewModel,
|
LocalAppViewModel provides appViewModel,
|
||||||
|
LocalAccountViewModel provides accountViewModel,
|
||||||
|
LocalChatViewModel provides chatViewModel,
|
||||||
|
LocalProfileViewModel provides profileViewModel,
|
||||||
|
LocalRelayViewModel provides relayViewModel,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavController provides navController,
|
LocalNavigator provides navigator,
|
||||||
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
|
NavDisplay(
|
||||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
backStack = backStack,
|
||||||
val sheetState = rememberModalBottomSheetState()
|
onBack = {
|
||||||
|
if (backStack.size > 1) {
|
||||||
LaunchedEffect(signerRequired) {
|
backStack.removeLastOrNull()
|
||||||
// Navigate to the home screen if the secret is already set
|
} else {
|
||||||
if (signerRequired == false) {
|
(context as? Activity)?.finish()
|
||||||
navController.navigate(Screen.Home) {
|
|
||||||
popUpTo(Screen.Onboarding) { inclusive = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the splash screen visible until the secret check is complete
|
|
||||||
if (signerRequired == null) {
|
|
||||||
return@CompositionLocalProvider
|
|
||||||
}
|
|
||||||
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = if (signerRequired!!) Screen.Onboarding else Screen.Home
|
|
||||||
) {
|
|
||||||
composable<Screen.Onboarding> { backStackEntry ->
|
|
||||||
OnboardingScreen(
|
|
||||||
onOpenImport = { navController.navigate(Screen.Import) },
|
|
||||||
onOpenNew = { navController.navigate(Screen.NewIdentity) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Import> { backStackEntry ->
|
|
||||||
val isCreating by viewModel.isCreating.collectAsState()
|
|
||||||
|
|
||||||
ImportScreen(
|
|
||||||
isLoading = isCreating,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSave = { secret ->
|
|
||||||
viewModel.importIdentity(secret)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.NewIdentity> { backStackEntry ->
|
|
||||||
val isCreating by viewModel.isCreating.collectAsState()
|
|
||||||
|
|
||||||
NewIdentityScreen(
|
|
||||||
isLoading = isCreating,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSave = { name, bio, uri ->
|
|
||||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
|
||||||
val picture = uri?.let {
|
|
||||||
context.contentResolver.openInputStream(it)?.use { input ->
|
|
||||||
input.readBytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.createIdentity(name, bio, picture, contentType)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Home> { backStackEntry ->
|
|
||||||
HomeScreen(
|
|
||||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
|
||||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Chat>(
|
|
||||||
deepLinks = listOf(
|
|
||||||
navDeepLink<Screen.Chat>(basePath = "coop://chat")
|
|
||||||
)
|
|
||||||
) { backStackEntry ->
|
|
||||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
|
||||||
ChatScreen(
|
|
||||||
id = chat.id,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Profile> { backStackEntry ->
|
|
||||||
val profile: Screen.Profile = backStackEntry.toRoute()
|
|
||||||
ProfileScreen(
|
|
||||||
pubkey = profile.pubkey,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.NewChat> { backStackEntry ->
|
|
||||||
NewChatScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Scan> { backStackEntry ->
|
|
||||||
ScanScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.MyQr> { backStackEntry ->
|
|
||||||
MyQrScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Relay> { backStackEntry ->
|
|
||||||
RelayScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the relay setup dialog if the msg relay list is empty
|
|
||||||
if (isRelayListEmpty) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight(0.5f)
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Messaging Relays are required",
|
|
||||||
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontStyle = FontStyle.Italic,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
viewModel.useDefaultMsgRelayList()
|
|
||||||
sheetState.hide()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
entryDecorators = listOf(
|
||||||
.fillMaxWidth()
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
) {
|
),
|
||||||
Text(
|
entryProvider = entryProvider {
|
||||||
text = "Continue",
|
entry<Screen.Home> {
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
HomeScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.RequestList> {
|
||||||
|
RequestListScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Onboarding> {
|
||||||
|
OnboardingScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Import> {
|
||||||
|
ImportScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.NewIdentity> {
|
||||||
|
NewIdentityScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Chat> { key ->
|
||||||
|
ChatScreen(id = key.id, screening = key.screening)
|
||||||
|
}
|
||||||
|
entry<Screen.NewChat> {
|
||||||
|
NewChatScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Profile> { key ->
|
||||||
|
ProfileScreen(pubkey = key.pubkey)
|
||||||
|
}
|
||||||
|
entry<Screen.UpdateProfile> {
|
||||||
|
UpdateProfileScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Scan> {
|
||||||
|
ScanScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.MyQr> {
|
||||||
|
MyQrScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.ContactList> {
|
||||||
|
ContactListScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Relay> {
|
||||||
|
RelayScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
class Navigator(private val backStack: NavBackStack<NavKey>) {
|
||||||
}
|
fun navigate(route: NavKey) {
|
||||||
|
backStack.add(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goBack() {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeAt(backStack.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrScanResult {
|
||||||
|
var content by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
content = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class CrashActivity : ComponentActivity() {
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val errorText = intent.getStringExtra("error") ?: "Unknown error"
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialExpressiveTheme {
|
||||||
|
Scaffold(
|
||||||
|
content = { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"App Crashed",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Please copy the log below and send it to the developer.",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorText,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = {
|
||||||
|
finish();
|
||||||
|
exitProcess(0)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Exit")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val clipboard =
|
||||||
|
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val data = ClipData.newPlainText("Crash Log", errorText)
|
||||||
|
clipboard.setPrimaryClip(data)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class ExternalSignerLauncher {
|
||||||
|
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||||
|
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
fun register(launcher: ActivityResultLauncher<Intent>) {
|
||||||
|
this.launcher = launcher
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val deferred = CompletableDeferred<ActivityResult>()
|
||||||
|
pendingResult = deferred
|
||||||
|
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun onResult(result: ActivityResult) {
|
||||||
|
pendingResult?.complete(result)
|
||||||
|
pendingResult = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,48 +1,134 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Process
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.viewmodels.AccountViewModel
|
||||||
|
import su.reya.coop.viewmodels.AppViewModel
|
||||||
|
import su.reya.coop.viewmodels.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodels.ProfileViewModel
|
||||||
|
import su.reya.coop.viewmodels.RelayViewModel
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: NostrViewModel by viewModels {
|
companion object {
|
||||||
|
val externalSignerLauncher = ExternalSignerLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val factory by lazy {
|
||||||
object : ViewModelProvider.Factory {
|
object : ViewModelProvider.Factory {
|
||||||
|
private val secretStore = SecretStore(this@MainActivity)
|
||||||
|
private val androidSigner =
|
||||||
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
|
private val appVM = AppViewModel(NostrManager.instance, secretStore)
|
||||||
|
private val profileVM = ProfileViewModel(NostrManager.instance, appVM)
|
||||||
|
private val chatVM = ChatViewModel(NostrManager.instance, appVM)
|
||||||
|
private val accountVM =
|
||||||
|
AccountViewModel(NostrManager.instance, secretStore, appVM, androidSigner)
|
||||||
|
private val relayVM = RelayViewModel(NostrManager.instance, appVM, chatVM, profileVM)
|
||||||
|
|
||||||
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 {
|
||||||
return NostrViewModel(NostrManager.instance, secretStore) as T
|
modelClass.isAssignableFrom(AppViewModel::class.java) -> appVM
|
||||||
|
modelClass.isAssignableFrom(AccountViewModel::class.java) -> accountVM
|
||||||
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatVM
|
||||||
|
modelClass.isAssignableFrom(ProfileViewModel::class.java) -> profileVM
|
||||||
|
modelClass.isAssignableFrom(RelayViewModel::class.java) -> relayVM
|
||||||
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
} as T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val appViewModel: AppViewModel by viewModels { factory }
|
||||||
|
private val accountViewModel: AccountViewModel by viewModels { factory }
|
||||||
|
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||||
|
private val profileViewModel: ProfileViewModel by viewModels { factory }
|
||||||
|
private val relayViewModel: RelayViewModel by viewModels { factory }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
throwable.printStackTrace()
|
||||||
|
|
||||||
|
Log.e(
|
||||||
|
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
|
||||||
|
)
|
||||||
|
|
||||||
|
// Start the Crash Activity
|
||||||
|
val intent = Intent(this, CrashActivity::class.java).apply {
|
||||||
|
putExtra("error", throwable.stackTraceToString())
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
|
||||||
|
// Exit
|
||||||
|
Process.killProcess(Process.myPid())
|
||||||
|
exitProcess(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val resultLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
externalSignerLauncher.onResult(result)
|
||||||
|
}
|
||||||
|
externalSignerLauncher.register(resultLauncher)
|
||||||
|
|
||||||
val splashScreen = installSplashScreen()
|
val splashScreen = installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(serviceIntent)
|
startForegroundService(serviceIntent)
|
||||||
} else {
|
|
||||||
startService(serviceIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the splash screen visible until the signer check is complete
|
// Keep the splash screen visible until the signer check is complete
|
||||||
splashScreen.setKeepOnScreenCondition {
|
splashScreen.setKeepOnScreenCondition {
|
||||||
viewModel.signerRequired.value == null
|
accountViewModel.signerRequired.value == null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind the lifecycle of the ViewModels
|
||||||
|
val lifecycle = ProcessLifecycleOwner.get().lifecycle
|
||||||
|
lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
appViewModel.viewModelScope.launch {
|
||||||
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
coroutineScope {
|
||||||
|
launch { chatViewModel.bindObservers() }
|
||||||
|
launch { profileViewModel.bindObservers() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
relayViewModel.observeSignerAndCheckRelays()
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App(viewModel = viewModel)
|
App(
|
||||||
|
appViewModel = appViewModel,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
chatViewModel = chatViewModel,
|
||||||
|
profileViewModel = profileViewModel,
|
||||||
|
relayViewModel = relayViewModel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
sealed interface Screen {
|
sealed interface Screen : NavKey {
|
||||||
|
companion object {
|
||||||
|
fun fromIntent(intent: Intent): Screen? {
|
||||||
|
val data = intent.data ?: return null
|
||||||
|
if (data.scheme != "coop") return null
|
||||||
|
|
||||||
|
return when (data.host) {
|
||||||
|
// Matches coop://chat/{id}
|
||||||
|
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) }
|
||||||
|
// Matches coop://profile/{pubkey}
|
||||||
|
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
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
|
||||||
|
data object UpdateProfile : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object NewChat : Screen
|
data object NewChat : Screen
|
||||||
|
|
||||||
|
|||||||
@@ -6,55 +6,78 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.annotation.RequiresApi
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.nostr.roomId
|
||||||
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 = NostrManager.instance
|
private val nostr by lazy { NostrManager.instance }
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun isUserInApp(): Boolean {
|
override fun onCreate() {
|
||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
startAsForeground()
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
createNotificationChannel()
|
if (intent?.action == "STOP_SERVICE") {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
val notification = createNotification()
|
// Start the nostr service in the foreground
|
||||||
startForeground(1, notification)
|
startAsForeground()
|
||||||
|
|
||||||
serviceScope.launch {
|
// Check if the service is already running
|
||||||
|
if (notificationJob?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
// Start the Nostr client
|
||||||
|
notificationJob = serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
|
|
||||||
|
// Create a database directory
|
||||||
val dbDir = File(filesDir, "nostr")
|
val dbDir = File(filesDir, "nostr")
|
||||||
dbDir.mkdirs()
|
dbDir.mkdirs()
|
||||||
|
|
||||||
// Initialize Nostr client
|
// Initialize Nostr client
|
||||||
|
try {
|
||||||
nostr.init(dbDir.absolutePath)
|
nostr.init(dbDir.absolutePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
nostr.connectBootstrapRelays()
|
nostr.connectBootstrapRelays()
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
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 {
|
||||||
@@ -66,19 +89,38 @@ class NostrForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to start Nostr in background: ${e.message}")
|
Log.e("Coop", "Failed to start Nostr", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
private fun isUserInApp(): Boolean {
|
||||||
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAsForeground() {
|
||||||
|
val notification = createNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStopServicePendingIntent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NostrForegroundService::class.java).apply {
|
||||||
|
action = "STOP_SERVICE"
|
||||||
|
}
|
||||||
|
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
val serviceChannel = NotificationChannel(
|
val serviceChannel = NotificationChannel(
|
||||||
"nostr_service_silent",
|
"nostr_service",
|
||||||
"Nostr Background Status",
|
"Nostr Background Status",
|
||||||
NotificationManager.IMPORTANCE_MIN
|
NotificationManager.IMPORTANCE_MIN
|
||||||
).apply {
|
).apply {
|
||||||
@@ -96,10 +138,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,17 +1,14 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
@@ -21,11 +18,15 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Badge
|
import androidx.compose.material3.Badge
|
||||||
import androidx.compose.material3.BadgedBox
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.LoadingIndicator
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -35,67 +36,96 @@ import androidx.compose.material3.TextField
|
|||||||
import androidx.compose.material3.TextFieldDefaults
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.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_cancel
|
||||||
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
import coop.composeapp.generated.resources.ic_send
|
import coop.composeapp.generated.resources.ic_send
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.nostr.Room
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.nostr.formatAsGroupHeader
|
||||||
|
import su.reya.coop.nostr.humanReadable
|
||||||
|
import su.reya.coop.nostr.roomId
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.shared.nameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||||
id: Long,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
val listState = rememberLazyListState()
|
// Get chat room by ID
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState()
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } }
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
|
// Show empty screen
|
||||||
if (room == null) {
|
if (room == null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
LoadingIndicator()
|
Text(
|
||||||
|
text = "Something went wrong.",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
|
||||||
|
val displayName by remember(room, currentUser) {
|
||||||
|
room?.nameFlow(profileViewModel, currentUser) ?: flowOf("Loading...")
|
||||||
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
|
val picture by remember(room) {
|
||||||
|
room?.pictureFlow(profileViewModel) ?: flowOf(null)
|
||||||
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
var newOtherMessages by remember { mutableIntStateOf(0) }
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
var requireScreening by remember { mutableStateOf(screening) }
|
||||||
|
|
||||||
|
val listState = rememberLazyListState()
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
|
|
||||||
val groupedMessages = remember(messages.toList()) {
|
val groupedMessages = remember(messages.toList()) {
|
||||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||||
}
|
}
|
||||||
@@ -105,27 +135,18 @@ fun ChatScreen(
|
|||||||
loading = true
|
loading = true
|
||||||
|
|
||||||
// Get messages
|
// Get messages
|
||||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||||
messages.clear()
|
messages.clear()
|
||||||
messages.addAll(initialMessages)
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
// Get msg relays for each member
|
|
||||||
val results = viewModel.chatRoomConnect(id)
|
|
||||||
results.forEach { (member, relays) ->
|
|
||||||
if (relays.isNotEmpty()) {
|
|
||||||
val metadata = viewModel.getMetadata(member).first { it != null }
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val name = profile?.displayName ?: profile?.name ?: member.short()
|
|
||||||
|
|
||||||
snackbarHostState.showSnackbar("Connected to messaging relays for $name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop loading spinner
|
// Stop loading spinner
|
||||||
loading = false
|
loading = false
|
||||||
|
|
||||||
|
// Get msg relays for each member
|
||||||
|
chatViewModel.chatRoomConnect(id)
|
||||||
|
|
||||||
// Handle new messages
|
// Handle new messages
|
||||||
viewModel.newEvents.collect { event ->
|
chatViewModel.newEvents.collect { event ->
|
||||||
if (event.roomId() == id) {
|
if (event.roomId() == id) {
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
@@ -152,15 +173,13 @@ fun ChatScreen(
|
|||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.clickable {
|
modifier = Modifier.clickable {
|
||||||
room.members.firstOrNull()?.let { pubkey ->
|
room?.members?.firstOrNull()?.let { pubkey ->
|
||||||
navController.navigate(Screen.Profile(pubkey.toBech32()))
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
LoadingIndicator(
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = picture,
|
||||||
@@ -185,7 +204,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -211,7 +230,12 @@ fun ChatScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
) {
|
) {
|
||||||
if (messages.isNotEmpty()) {
|
if (requireScreening) {
|
||||||
|
room?.let { ScreenerCard(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
when (messages.isNotEmpty()) {
|
||||||
|
true -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
@@ -222,26 +246,34 @@ fun ChatScreen(
|
|||||||
) {
|
) {
|
||||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
||||||
items(
|
items(
|
||||||
messagesInGroup,
|
items = messagesInGroup,
|
||||||
key = { it.id()?.toBech32()!! }) { event ->
|
key = { it.id()?.toBech32() ?: it.hashCode() }
|
||||||
ChatMessage(event)
|
) {
|
||||||
|
ChatMessage(it)
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
DateSeparator(dateHeader)
|
DateSeparator(dateHeader)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
false -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "No messages yet",
|
text = "No messages yet",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
@@ -252,20 +284,178 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (requireScreening) {
|
||||||
|
true -> {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { navigator.goBack() },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Reject",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { requireScreening = false },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Accept",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
ChatInput(
|
ChatInput(
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
onSend = {
|
onSend = {
|
||||||
viewModel.sendMessage(id, text)
|
chatViewModel.sendMessage(id, text)
|
||||||
text = ""
|
text = ""
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScreenerCard(room: Room) {
|
||||||
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isContact by remember { mutableStateOf(false) }
|
||||||
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
|
val picture = profile?.picture
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) {
|
||||||
|
scope.launch {
|
||||||
|
// Check contact
|
||||||
|
profileViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
|
// Get mutual contacts
|
||||||
|
profileViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
|
// Get the last activity
|
||||||
|
profileViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 48.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
picture = picture,
|
||||||
|
description = "Profile picture",
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pubkey.short(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isContact) "Contact" else "Not a contact",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check_circle),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DateSeparator(date: String) {
|
fun DateSeparator(date: String) {
|
||||||
Box(
|
Box(
|
||||||
@@ -286,8 +476,9 @@ fun DateSeparator(date: String) {
|
|||||||
fun ChatMessage(
|
fun ChatMessage(
|
||||||
rumor: UnsignedEvent
|
rumor: UnsignedEvent
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val currentUser = viewModel.currentUser()
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val currentUser = accountViewModel.nostr.signer.currentUser
|
||||||
val isMine = rumor.author() == currentUser
|
val isMine = rumor.author() == currentUser
|
||||||
|
|
||||||
val bubbleShape = if (isMine) {
|
val bubbleShape = if (isMine) {
|
||||||
@@ -321,7 +512,7 @@ fun ChatMessage(
|
|||||||
onClick = {
|
onClick = {
|
||||||
val id = rumor.id()
|
val id = rumor.id()
|
||||||
if (id != null) {
|
if (id != null) {
|
||||||
val sent = viewModel.isMessageSent(id)
|
val sent = chatViewModel.isMessageSent(id)
|
||||||
println("Sent: $sent")
|
println("Sent: $sent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -343,12 +534,9 @@ fun ChatInput(
|
|||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
onSend: () -> Unit
|
onSend: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
Surface(modifier = Modifier.fillMaxWidth()) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
.height(IntrinsicSize.Min),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
verticalAlignment = Alignment.Bottom
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
@@ -365,9 +553,7 @@ fun ChatInput(
|
|||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
modifier = Modifier
|
modifier = Modifier.size(56.dp),
|
||||||
.fillMaxHeight()
|
|
||||||
.aspectRatio(1f),
|
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_check
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
|
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 profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
|
val contactList by profileViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "My Contacts",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
|
contentDescription = "Scanner"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Contact") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddContactDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Contact"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Contact") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
.padding(innerPadding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
|
) {
|
||||||
|
if (contactList.isNotEmpty()) {
|
||||||
|
contactList.forEachIndexed { index, pubkey ->
|
||||||
|
ContactListItem(
|
||||||
|
pubkey = pubkey,
|
||||||
|
index = index,
|
||||||
|
total = contactList.size,
|
||||||
|
onClick = {})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No contacts yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your contacts will appear here",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (openAddContactDialog) {
|
||||||
|
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.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 = profileViewModel.addContact(contact)
|
||||||
|
if (success) onDismissRequest()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contact,
|
||||||
|
onValueChange = { contact = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = contact.isNotEmpty() && !verifyContact(contact)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Contact Address") },
|
||||||
|
placeholder = { Text(text = "npub1... or user@example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Contact address is invalid")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add contact you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListItem(
|
||||||
|
pubkey: PublicKey,
|
||||||
|
index: Int,
|
||||||
|
total: Int = 0,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||||
|
val picture = profile?.picture
|
||||||
|
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = { profileViewModel.removeContact(pubkey) },
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
|
index = index,
|
||||||
|
count = total
|
||||||
|
),
|
||||||
|
leadingContent = {
|
||||||
|
Avatar(
|
||||||
|
picture = picture,
|
||||||
|
description = displayName,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyContact(address: String): Boolean {
|
||||||
|
return try {
|
||||||
|
if (address.contains("@")) Nip05Address.parse(address)
|
||||||
|
else PublicKey.parse(address)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse contact: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -36,6 +46,7 @@ import androidx.compose.material3.SegmentedListItem
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
@@ -61,79 +72,116 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalAppViewModel
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
|
import su.reya.coop.LocalRelayViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.nostr.Room
|
||||||
|
import su.reya.coop.nostr.RoomKind
|
||||||
|
import su.reya.coop.nostr.ago
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.shared.nameFlow
|
||||||
import su.reya.coop.shared.pictureFlow
|
import su.reya.coop.shared.pictureFlow
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen() {
|
||||||
onOpenChat: (Long) -> Unit,
|
val context = LocalContext.current
|
||||||
onNewChat: () -> Unit,
|
val navigator = LocalNavigator.current
|
||||||
) {
|
val qrScanResult = LocalScanResult.current
|
||||||
val navController = LocalNavController.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val clipboardManager = LocalClipboard.current
|
val clipboardManager = LocalClipboard.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val appViewModel = LocalAppViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
val relayViewModel = LocalRelayViewModel.current
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
|
||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
|
val currentUserProfile = profileViewModel.getMetadata(currentUser)
|
||||||
|
|
||||||
|
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
val isRelayListEmpty by relayViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
|
val isBannerDismissed by appViewModel.isNotificationBannerDismissed.collectAsState()
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
var isBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
var isNotificationEnabled by remember {
|
||||||
val qrResult by savedStateHandle
|
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
}
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { _ ->
|
||||||
|
// State will be updated by LifecycleResumeEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition chat rooms into requests and ongoing
|
||||||
|
val (requests, ongoing) = remember(chatRooms) {
|
||||||
|
chatRooms.partition { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
|
LifecycleResumeEffect(context) {
|
||||||
|
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (qrResult == null) {
|
chatViewModel.getChatRooms()
|
||||||
viewModel.getChatRooms()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
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))
|
||||||
navController.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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,14 +194,22 @@ fun HomeScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
title = {
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Coop",
|
text = "Coop",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
)
|
)
|
||||||
|
if (isSyncing) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// QR Scanner
|
// QR Scanner
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -184,7 +240,7 @@ fun HomeScreen(
|
|||||||
state = rememberTooltipState(),
|
state = rememberTooltipState(),
|
||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewChat,
|
onClick = { navigator.navigate(Screen.NewChat) },
|
||||||
expanded = expandedFab,
|
expanded = expandedFab,
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -197,10 +253,73 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Get message notifications",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryFixed,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Make sure you know when you have new messages.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { appViewModel.dismissNotificationBanner() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(text = "Maybe later")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
// For older versions, navigate the user directly to App Notification Settings
|
||||||
|
val intent =
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(
|
||||||
|
Settings.EXTRA_APP_PACKAGE,
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(text = "Turn on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
@@ -211,7 +330,7 @@ fun HomeScreen(
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
viewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -258,22 +377,30 @@ 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 = { onOpenChat(room.id) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val pubkey = viewModel.currentUser()
|
val pubkey = accountViewModel.nostr.signer.currentUser
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||||
|
|
||||||
val userName =
|
val userName =
|
||||||
@@ -329,7 +456,8 @@ fun HomeScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
pubkey?.let {
|
pubkey?.let {
|
||||||
val bech32 = it.toBech32()
|
val bech32 = it.toBech32()
|
||||||
val data = ClipData.newPlainText(bech32, bech32)
|
val data =
|
||||||
|
ClipData.newPlainText(bech32, bech32)
|
||||||
clipboardManager.setClipEntry(ClipEntry(data))
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +467,7 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
dismissAndRun { navController.navigate(Screen.MyQr) }
|
dismissAndRun { navigator.navigate(Screen.MyQr) }
|
||||||
},
|
},
|
||||||
shape = MaterialShapes.Square.toShape()
|
shape = MaterialShapes.Square.toShape()
|
||||||
) {
|
) {
|
||||||
@@ -355,17 +483,246 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
|
if (isRelayListEmpty) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { relayViewModel.dismissRelayWarning() },
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.5f)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Messaging Relays are missing",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
shape = MaterialShapes.Circle.toShape(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "X",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Other people won't be able to send you messages.",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
shape = MaterialShapes.Circle.toShape(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "X",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "You cannot store your messages.",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "If you believe this is a mistake, please click the Retry button to check again.",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (isBusy) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
enabled = !isBusy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
isBusy = true
|
||||||
|
try {
|
||||||
|
relayViewModel.refetchMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
|
}
|
||||||
|
isBusy = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Retry",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
enabled = !isBusy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
relayViewModel.useDefaultMsgRelayList()
|
||||||
|
sheetState.hide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Use Default",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewRequests(requests: List<Room>) {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
|
val total = requests.size
|
||||||
|
val firstRoom = requests.getOrNull(0)
|
||||||
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
|
val firstName by remember(firstRoom) {
|
||||||
|
firstRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||||
|
}.collectAsStateWithLifecycle("Loading...")
|
||||||
|
|
||||||
|
val secondName by remember(secondRoom) {
|
||||||
|
secondRoom?.nameFlow(profileViewModel) ?: flowOf("")
|
||||||
|
}.collectAsStateWithLifecycle("")
|
||||||
|
|
||||||
|
val supportingText = when {
|
||||||
|
total == 1 && firstRoom != null -> {
|
||||||
|
val message = firstRoom.lastMessage ?: ""
|
||||||
|
"$firstName: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
total == 2 -> {
|
||||||
|
"$firstName and $secondName"
|
||||||
|
}
|
||||||
|
|
||||||
|
total > 2 -> {
|
||||||
|
val othersCount = total - 2
|
||||||
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
|
"$firstName, $secondName and $othersText"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
navigator.navigate(Screen.RequestList)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_request),
|
||||||
|
contentDescription = "Requests",
|
||||||
|
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "Requests",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (supportingText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room.nameFlow(profileViewModel) }.collectAsStateWithLifecycle("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room.pictureFlow(profileViewModel) }.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
@@ -392,8 +749,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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -408,13 +767,13 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
fun BottomMenuList(
|
fun BottomMenuList(
|
||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Relay Management" to { navController.navigate(Screen.Relay) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Spams & Blocks" to { },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Contacts" to { },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { }
|
"Settings" to { }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -439,7 +798,7 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.logout() },
|
onClick = { onDismiss { accountViewModel.logout() } },
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ 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.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -49,17 +48,21 @@ import androidx.compose.ui.text.input.ImeAction
|
|||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
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_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.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.LocalNavController
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalAppViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
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.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -68,53 +71,47 @@ import su.reya.coop.short
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen() {
|
||||||
isLoading: Boolean,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSave: (secret: String) -> Unit
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
|
val qrScanResult = LocalScanResult.current
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val appViewModel = LocalAppViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
val metadata by remember(pubkey) {
|
|
||||||
if (pubkey != null) {
|
|
||||||
viewModel.getMetadata(pubkey!!)
|
|
||||||
} else {
|
|
||||||
MutableStateFlow(null)
|
|
||||||
}
|
|
||||||
}.collectAsState(null)
|
|
||||||
|
|
||||||
|
// Get metadata when pubkey changes
|
||||||
|
val metadata by remember(pubkey) {
|
||||||
|
pubkey?.let(profileViewModel::getMetadata) ?: flowOf(null)
|
||||||
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
LaunchedEffect(qrScanResult.content) {
|
||||||
val qrResult by savedStateHandle
|
qrScanResult.content?.let { result ->
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
|
||||||
qrResult?.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
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +130,7 @@ fun ImportScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -141,7 +138,7 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -213,6 +210,7 @@ fun ImportScreen(
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
|
enabled = !isBusy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
@@ -225,10 +223,10 @@ fun ImportScreen(
|
|||||||
),
|
),
|
||||||
visualTransformation = PasswordVisualTransformation('*'),
|
visualTransformation = PasswordVisualTransformation('*'),
|
||||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
if (secret.isEmpty()) {
|
if (secret.isEmpty()) {
|
||||||
@@ -250,24 +248,28 @@ fun ImportScreen(
|
|||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (pubkey == null) {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
if (pubkey == null) {
|
||||||
}
|
accountViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
} else {
|
} else {
|
||||||
onSave(secret)
|
// Import the identity
|
||||||
|
accountViewModel.importIdentity(secret)
|
||||||
|
// Navigate to the home screen
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !isLoading,
|
enabled = secret.isNotBlank() && !isBusy,
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
if (isBusy) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (pubkey == null) "Verify" else "Continue",
|
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ 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
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen(
|
fun MyQrScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -41,7 +41,7 @@ fun MyQrScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
|
|||||||
@@ -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,37 +55,35 @@ 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.LocalNavController
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
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.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
|
||||||
fun NewChatScreen(
|
fun NewChatScreen() {
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val qrScanResult = LocalScanResult.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
|
||||||
|
val contactList by profileViewModel.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("") }
|
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
|
||||||
val qrResult by savedStateHandle
|
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
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 {
|
||||||
@@ -97,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 = profileViewModel.searchByAddress(query)
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = viewModel.searchByNostr(query)
|
val results = profileViewModel.searchByNostr(query)
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
@@ -111,13 +110,18 @@ fun NewChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching { PublicKey.parse(result) }
|
// Verify the content
|
||||||
.onSuccess { pubkey -> selectedReceivers.add(pubkey) }
|
runCatching {
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
PublicKey.parse(result)
|
||||||
|
}.onSuccess { pubkey ->
|
||||||
|
selectedReceivers.add(pubkey)
|
||||||
|
}.onFailure { e ->
|
||||||
|
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||||
|
}
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +140,7 @@ fun NewChatScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -144,7 +148,7 @@ fun NewChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -167,8 +171,8 @@ fun NewChatScreen(
|
|||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
expanded = false,
|
expanded = false,
|
||||||
icon = {
|
icon = {
|
||||||
@@ -258,8 +262,8 @@ 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))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
@@ -269,8 +273,8 @@ 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))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
@@ -286,8 +290,8 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
@@ -372,8 +376,8 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
|
|||||||
@@ -1,273 +1,35 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.imePadding
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialShapes
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.material3.toShape
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.remember
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.setValue
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.Alignment
|
import su.reya.coop.LocalAccountViewModel
|
||||||
import androidx.compose.ui.Modifier
|
import su.reya.coop.LocalAppViewModel
|
||||||
import androidx.compose.ui.draw.clip
|
import su.reya.coop.LocalNavigator
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import su.reya.coop.LocalProfileViewModel
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import su.reya.coop.Screen
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import su.reya.coop.shared.ProfileEditor
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import coop.composeapp.generated.resources.ic_plus
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen(
|
fun NewIdentityScreen() {
|
||||||
isLoading: Boolean,
|
val appViewModel = LocalAppViewModel.current
|
||||||
onBack: () -> Unit,
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
) {
|
val navigator = LocalNavigator.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val scope = rememberCoroutineScope()
|
||||||
val focusManager = LocalFocusManager.current
|
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
var name by remember { mutableStateOf("") }
|
ProfileEditor(
|
||||||
var bio by remember { mutableStateOf("") }
|
title = "Create a new identity",
|
||||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
buttonLabel = "Continue",
|
||||||
|
isBusy = isBusy,
|
||||||
val launcher = rememberLauncherForActivityResult(
|
onBack = { navigator.goBack() },
|
||||||
contract = ActivityResultContracts.GetContent()
|
onConfirm = { name, bio, bytes, type ->
|
||||||
) { uri: Uri? ->
|
scope.launch {
|
||||||
picture = uri
|
accountViewModel.createIdentity(name, bio, bytes, type, profileViewModel)
|
||||||
}
|
navigator.navigate(Screen.Home)
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Create a new identity",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding())
|
|
||||||
.imePadding(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(120.dp)
|
|
||||||
.clip(MaterialShapes.Pentagon.toShape())
|
|
||||||
.clickable { launcher.launch("image/*") },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (picture != null) {
|
|
||||||
AsyncImage(
|
|
||||||
model = picture,
|
|
||||||
contentDescription = "Profile picture",
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_plus),
|
|
||||||
contentDescription = "Pick avatar",
|
|
||||||
modifier = Modifier.size(48.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f, fill = true),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "What others should call you?",
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
|
||||||
if (name.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"Alice",
|
|
||||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
|
||||||
alpha = 0.5f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Your bio (optional)",
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
BasicTextField(
|
|
||||||
value = bio,
|
|
||||||
onValueChange = { bio = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
maxLines = 3,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
imeAction = ImeAction.Done,
|
|
||||||
),
|
|
||||||
keyboardActions = KeyboardActions(
|
|
||||||
onDone = {
|
|
||||||
focusManager.clearFocus()
|
|
||||||
}
|
|
||||||
),
|
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
|
||||||
if (bio.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
"I love cat",
|
|
||||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
|
||||||
alpha = 0.5f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onSave(name, bio, picture)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
|
||||||
enabled = name.isNotBlank() && !isLoading,
|
|
||||||
) {
|
|
||||||
if (isLoading) {
|
|
||||||
LoadingIndicator()
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = "Continue",
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -13,13 +14,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@@ -27,6 +32,7 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.graphics.drawscope.translate
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
@@ -34,18 +40,29 @@ import androidx.compose.ui.text.buildAnnotatedString
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.getExpressiveFontFamily
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
fun OnboardingScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
val expressiveFont = getExpressiveFontFamily()
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
|
||||||
val annotatedText = buildAnnotatedString {
|
val annotatedText = buildAnnotatedString {
|
||||||
append("By using Coop, you agree to accept\nour ")
|
append("By using Coop, you agree to accept\nour ")
|
||||||
// Push "Terms of Use" link
|
// Push "Terms of Use" link
|
||||||
@@ -127,7 +144,7 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(24.dp))
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = onOpenNew,
|
onClick = { navigator.navigate(Screen.NewIdentity) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.size(ButtonDefaults.MediumContainerHeight),
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
@@ -138,8 +155,45 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
OutlinedButton(
|
FilledTonalButton(
|
||||||
onClick = onOpenImport,
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (accountViewModel.isExternalSignerAvailable()) {
|
||||||
|
try {
|
||||||
|
accountViewModel.connectExternalSigner()
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = "External signer not installed. Please install Amber or alternatives.",
|
||||||
|
actionLabel = "Install",
|
||||||
|
withDismissAction = true,
|
||||||
|
duration = SnackbarDuration.Long
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) {
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Connect with Amber",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { navigator.navigate(Screen.Import) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
|||||||
@@ -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,8 +44,9 @@ 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.LocalNavController
|
import su.reya.coop.LocalChatViewModel
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
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.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -54,30 +55,29 @@ import su.reya.coop.short
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileScreen(
|
fun ProfileScreen(pubkey: String) {
|
||||||
onBack: () -> Unit,
|
|
||||||
pubkey: String
|
|
||||||
) {
|
|
||||||
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
|
val metadataFlow = remember(pubkey) { profileViewModel.getMetadata(pubkey) }
|
||||||
|
val metadata by metadataFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
val nip05 = profile?.nip05 ?: pubkey.short()
|
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val details = remember(profile) {
|
val details = remember(profile) {
|
||||||
listOf(
|
listOf(
|
||||||
"Username:" to (profile?.name ?: "None"),
|
"Username:" to (profile?.name ?: "None"),
|
||||||
"Website:" to (profile?.website ?: "None"),
|
"Website:" to (profile?.website ?: "None"),
|
||||||
"Lightning Address:" to (profile?.lud16 ?: "None"),
|
"₿ Lightning Address:" to (profile?.lud16 ?: "None"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +86,9 @@ fun ProfileScreen(
|
|||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { },
|
title = { /* empty */ },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -161,8 +161,8 @@ fun ProfileScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navController.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) }
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = "Message",
|
text = "Message",
|
||||||
style = MaterialTheme.typography.labelSmall
|
style = MaterialTheme.typography.labelMedium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -3,48 +3,80 @@ package su.reya.coop.screens
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedListItem
|
import androidx.compose.material3.SegmentedListItem
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_check
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalRelayViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayScreen(
|
fun RelayScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val relayViewModel = LocalRelayViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
||||||
|
|
||||||
@@ -60,9 +92,12 @@ fun RelayScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||||
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(viewModel.currentUserRelayList())
|
relayList.putAll(relayViewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(relayViewModel.currentUserMsgRelayList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -80,15 +115,39 @@ fun RelayScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Relay") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddRelayDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Relay"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Relay") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -113,7 +172,8 @@ fun RelayScreen(
|
|||||||
if (msgRelayList.isNotEmpty()) {
|
if (msgRelayList.isNotEmpty()) {
|
||||||
msgRelayList.forEachIndexed { index, relayUrl ->
|
msgRelayList.forEachIndexed { index, relayUrl ->
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = { },
|
onClick = { /* No action */ },
|
||||||
|
onLongClick = { relayToDelete = relayUrl.toString() },
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = msgRelayList.size
|
count = msgRelayList.size
|
||||||
@@ -233,4 +293,223 @@ fun RelayScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (openAddRelayDialog) {
|
||||||
|
AddRelayDialog(
|
||||||
|
onDismissRequest = { openAddRelayDialog = false },
|
||||||
|
onMsgRelayAdded = { newRelay ->
|
||||||
|
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||||
|
},
|
||||||
|
onRelayAdded = { newRelay, metadata ->
|
||||||
|
relayList[RelayUrl.parse(newRelay)] = metadata
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayToDelete != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { relayToDelete = null },
|
||||||
|
title = { Text("Remove Relay") },
|
||||||
|
text = { Text("Are you sure you want to remove $relayToDelete?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (msgRelayList.size == 1) {
|
||||||
|
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||||
|
relayToDelete = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
relayViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
|
relayToDelete = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Confirm")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { relayToDelete = null }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddRelayDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
|
) {
|
||||||
|
val relayViewModel = LocalRelayViewModel.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var relayAddress by remember { mutableStateOf("") }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
val roles = listOf("Messaging", "Inbox", "Outbox")
|
||||||
|
val (selected, onSelected) = remember { mutableStateOf(roles[0]) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Relay",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onDismissRequest() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (!isError) {
|
||||||
|
when (selected) {
|
||||||
|
"Messaging" -> {
|
||||||
|
relayViewModel.addMsgRelay(relayAddress)
|
||||||
|
onMsgRelayAdded(relayAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
"Inbox" -> {
|
||||||
|
relayViewModel.addInboxRelay(relayAddress)
|
||||||
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"Outbox" -> {
|
||||||
|
relayViewModel.addOutboxRelay(relayAddress)
|
||||||
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = relayAddress,
|
||||||
|
onValueChange = { relayAddress = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Relay Address") },
|
||||||
|
placeholder = { Text(text = "wss://relay.example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Invalid format. Must start with wss://")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add relays you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Relay Roles",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup(),
|
||||||
|
) {
|
||||||
|
roles.forEach { text ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.selectable(
|
||||||
|
onClick = { onSelected(text) },
|
||||||
|
selected = (text == selected),
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (text == selected),
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyRelayUrl(url: String): Boolean {
|
||||||
|
return try {
|
||||||
|
RelayUrl.parse(url)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse relay url: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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.Screen
|
||||||
|
import su.reya.coop.nostr.RoomKind
|
||||||
|
|
||||||
|
@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
|
||||||
@@ -30,34 +29,29 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
||||||
import org.publicvalue.multiplatform.qrcode.CodeType
|
import org.publicvalue.multiplatform.qrcode.CodeType
|
||||||
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanScreen(
|
fun ScanScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val qrScanResult = LocalScanResult.current
|
||||||
val onResult: (String) -> Unit = { result ->
|
|
||||||
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -76,7 +70,8 @@ fun ScanScreen(
|
|||||||
ScannerWithPermissions(
|
ScannerWithPermissions(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onScanned = {
|
onScanned = {
|
||||||
onResult(it)
|
qrScanResult.content = it
|
||||||
|
navigator.goBack()
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
types = listOf(CodeType.QR),
|
types = listOf(CodeType.QR),
|
||||||
@@ -101,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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalAccountViewModel
|
||||||
|
import su.reya.coop.LocalAppViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalProfileViewModel
|
||||||
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateProfileScreen() {
|
||||||
|
val appViewModel = LocalAppViewModel.current
|
||||||
|
val accountViewModel = LocalAccountViewModel.current
|
||||||
|
val profileViewModel = LocalProfileViewModel.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val currentUser = accountViewModel.nostr.signer.currentUser ?: return
|
||||||
|
val metadata by profileViewModel.getMetadata(currentUser).collectAsStateWithLifecycle()
|
||||||
|
val isBusy by appViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
|
||||||
|
ProfileEditor(
|
||||||
|
title = "Update profile",
|
||||||
|
buttonLabel = "Save changes",
|
||||||
|
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||||
|
initialBio = profile?.about ?: "",
|
||||||
|
initialPicture = profile?.picture,
|
||||||
|
isBusy = isBusy,
|
||||||
|
onBack = { navigator.goBack() },
|
||||||
|
onConfirm = { name, bio, bytes, type ->
|
||||||
|
scope.launch {
|
||||||
|
profileViewModel.updateProfile(name, bio, bytes, type)
|
||||||
|
navigator.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package su.reya.coop.shared
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileEditor(
|
||||||
|
title: String,
|
||||||
|
buttonLabel: String,
|
||||||
|
initialName: String = "",
|
||||||
|
initialBio: String = "",
|
||||||
|
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||||
|
isBusy: Boolean = false,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||||
|
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||||
|
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||||
|
|
||||||
|
val hasPicture = remember(picture) {
|
||||||
|
when (picture) {
|
||||||
|
null -> false
|
||||||
|
is String -> (picture as CharSequence).isNotBlank()
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
picture = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title, style = MaterialTheme.typography.titleMediumEmphasized) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.clip(MaterialShapes.Pentagon.toShape())
|
||||||
|
.clickable { launcher.launch("image/*") },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (hasPicture) {
|
||||||
|
AsyncImage(
|
||||||
|
model = picture,
|
||||||
|
contentDescription = "Profile picture",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "Pick avatar",
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, fill = true),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "What others should call you?",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Alice",
|
||||||
|
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Your bio (optional)",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = bio,
|
||||||
|
onValueChange = { bio = it },
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = MaterialTheme.colorScheme.primaryFixed,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (bio.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"I love cat",
|
||||||
|
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
|
onClick = {
|
||||||
|
val scope = CoroutineScope(Dispatchers.Main)
|
||||||
|
scope.launch {
|
||||||
|
val bytes = withContext(Dispatchers.IO) {
|
||||||
|
(picture as? Uri)?.let {
|
||||||
|
context.contentResolver.openInputStream(it)?.readBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val type =
|
||||||
|
(picture as? Uri)?.let { context.contentResolver.getType(it) }
|
||||||
|
onConfirm(name, bio, bytes, type)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = name.isNotBlank() && !isBusy
|
||||||
|
) {
|
||||||
|
if (isBusy) {
|
||||||
|
LoadingIndicator()
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = buttonLabel,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,30 +4,41 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import su.reya.coop.NostrViewModel
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.nostr.Room
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import su.reya.coop.viewmodels.ProfileViewModel
|
||||||
|
|
||||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
fun Room.nameFlow(
|
||||||
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
|
profileViewModel: ProfileViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): Flow<String> {
|
||||||
|
// Return early if there's a custom subject/room name
|
||||||
|
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
|
||||||
|
|
||||||
val memberFlows = members.map { viewModel.getMetadata(it) }
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
|
if (displayMembers.isEmpty()) return flowOf("Unknown")
|
||||||
|
|
||||||
|
return combine(displayMembers.map { profileViewModel.getMetadata(it) }) { metadataArray ->
|
||||||
|
val names = metadataArray.mapIndexed { i, metadata ->
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
profile?.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
?: profile?.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: displayMembers[i].short()
|
||||||
|
}
|
||||||
|
|
||||||
return combine(memberFlows) { metadataArray ->
|
|
||||||
if (isGroup()) {
|
if (isGroup()) {
|
||||||
val profiles = metadataArray.map { it?.asRecord() }
|
val combined = names.joinToString(", ")
|
||||||
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
|
val extraCount = members.size - names.size
|
||||||
var combined = names.joinToString(", ")
|
if (extraCount > 0) "$combined, +$extraCount" else combined
|
||||||
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
|
|
||||||
combined.ifBlank { "Unknown group" }
|
|
||||||
} else {
|
} else {
|
||||||
val profile = metadataArray.firstOrNull()?.asRecord()
|
val name = names.first()
|
||||||
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
|
if (displayMembers.first() == currentUser) "$name (you)" else name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
fun Room.pictureFlow(profileViewModel: ProfileViewModel): Flow<String?> {
|
||||||
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
|
val firstMember = members.firstOrNull() ?: return flowOf(null)
|
||||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
return profileViewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "37"
|
||||||
android-minSdk = "24"
|
android-minSdk = "26"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "37"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.18.0"
|
androidx-core = "1.18.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-navigation = "2.9.8"
|
|
||||||
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.0"
|
||||||
@@ -17,6 +16,7 @@ junit = "4.13.2"
|
|||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
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"
|
||||||
ktor = "3.5.0"
|
ktor = "3.5.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
@@ -31,7 +31,6 @@ androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", vers
|
|||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
|
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
@@ -49,6 +48,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
|
|||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
|
||||||
|
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -30,9 +30,10 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
implementation("com.squareup.okio:okio:3.16.2")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
|
|
||||||
import io.ktor.client.plugins.websocket.webSocketSession
|
|
||||||
import io.ktor.client.request.url
|
|
||||||
import io.ktor.websocket.Frame
|
|
||||||
import io.ktor.websocket.close
|
|
||||||
import io.ktor.websocket.readBytes
|
|
||||||
import io.ktor.websocket.readText
|
|
||||||
import kotlinx.coroutines.cancel
|
|
||||||
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
|
||||||
import rust.nostr.sdk.ConnectionMode
|
|
||||||
import rust.nostr.sdk.CustomWebSocketTransport
|
|
||||||
import rust.nostr.sdk.WebSocketAdapter
|
|
||||||
import rust.nostr.sdk.WebSocketAdapterWrapper
|
|
||||||
import rust.nostr.sdk.WebSocketMessage
|
|
||||||
|
|
||||||
class KtorWebSocketAdapter(
|
|
||||||
private val client: HttpClient,
|
|
||||||
private val session: DefaultClientWebSocketSession
|
|
||||||
) : WebSocketAdapter {
|
|
||||||
|
|
||||||
override suspend fun send(msg: WebSocketMessage) {
|
|
||||||
try {
|
|
||||||
when (msg) {
|
|
||||||
is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
|
|
||||||
is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
|
|
||||||
is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
|
|
||||||
is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Attempted to send on a closed WebSocket: ${e.message}")
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun recv(): WebSocketMessage? {
|
|
||||||
return try {
|
|
||||||
when (val frame = session.incoming.receive()) {
|
|
||||||
is Frame.Text -> WebSocketMessage.Text(frame.readText())
|
|
||||||
is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
|
|
||||||
is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
|
|
||||||
is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
} catch (e: ClosedReceiveChannelException) {
|
|
||||||
null
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun closeConnection() {
|
|
||||||
session.cancel()
|
|
||||||
session.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
|
|
||||||
override fun supportPing(): Boolean = false
|
|
||||||
|
|
||||||
override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
|
|
||||||
try {
|
|
||||||
val session = httpClient.webSocketSession {
|
|
||||||
url(url)
|
|
||||||
}
|
|
||||||
val adapter = KtorWebSocketAdapter(httpClient, session)
|
|
||||||
return WebSocketAdapterWrapper(adapter)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
8
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,835 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
|
||||||
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.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.SleepWhenIdle
|
|
||||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.TagKind
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
|
||||||
import rust.nostr.sdk.extractRelayList
|
|
||||||
import rust.nostr.sdk.giftWrapAsync
|
|
||||||
import rust.nostr.sdk.initLogger
|
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
|
||||||
import kotlin.time.Duration
|
|
||||||
|
|
||||||
object NostrManager {
|
|
||||||
val instance = Nostr()
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Add these to the Nostr class
|
|
||||||
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) {
|
|
||||||
try {
|
|
||||||
if (isInitialized.value) return
|
|
||||||
|
|
||||||
// Initialize the logger for nostr client
|
|
||||||
initLogger(LogLevel.DEBUG)
|
|
||||||
|
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
|
||||||
val gossip = NostrGossip.inMemory()
|
|
||||||
val idleTimeout = Duration.parse("5m")
|
|
||||||
val httpClient = HttpClient {
|
|
||||||
install(WebSockets)
|
|
||||||
}
|
|
||||||
|
|
||||||
client =
|
|
||||||
ClientBuilder()
|
|
||||||
.signer(signer)
|
|
||||||
.websocketTransport(CoopWebSocketClient(httpClient))
|
|
||||||
.database(lmdb)
|
|
||||||
.gossip(gossip)
|
|
||||||
.gossipConfig(
|
|
||||||
GossipConfig()
|
|
||||||
.noBackgroundRefresh()
|
|
||||||
.fetchTimeout(Duration.parse("2s"))
|
|
||||||
.syncIdleTimeout(Duration.parse("100ms"))
|
|
||||||
.syncInitialTimeout(Duration.parse("100ms"))
|
|
||||||
)
|
|
||||||
.verifySubscriptions(false)
|
|
||||||
.automaticAuthentication(true)
|
|
||||||
.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() {
|
|
||||||
// Bootstrap relays
|
|
||||||
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
|
|
||||||
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
|
|
||||||
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
|
|
||||||
|
|
||||||
|
|
||||||
// Indexer relay for NIP-65 discovery
|
|
||||||
client?.addRelay(
|
|
||||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
|
||||||
capabilities = RelayCapabilities.gossip()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Connect to all bootstrap relays and wait for all connections to be established
|
|
||||||
client?.connect(Duration.parse("2s"))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disconnect() {
|
|
||||||
client?.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun exit() {
|
|
||||||
signer.switch(Keys.generate())
|
|
||||||
deviceSigner = null
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = "all-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) // 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 == "all-gift-wraps" || subscriptionId == "newest-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 {
|
|
||||||
val currentUser =
|
|
||||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
// Ensure the rumor ID is set
|
|
||||||
val rumor = rumor.ensureId()
|
|
||||||
val roomId = rumor.roomId()
|
|
||||||
|
|
||||||
// Construct reference tags
|
|
||||||
val tags = listOf(
|
|
||||||
Tag.identifier(giftId.toHex()),
|
|
||||||
Tag.event(rumor.id()!!),
|
|
||||||
Tag.reference(roomId.toString()),
|
|
||||||
Tag.custom(TagKind.Unknown("k"), listOf("dm"))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set event kind
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
|
||||||
|
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
|
||||||
.tags(tags)
|
|
||||||
.build(currentUser)
|
|
||||||
.signWithKeys(Keys.generate())
|
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
|
||||||
// Check if the rumor is already cached
|
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
|
||||||
if (cachedRumor != null) return cachedRumor
|
|
||||||
|
|
||||||
// Get all signers
|
|
||||||
val signers = listOfNotNull(signer, deviceSigner)
|
|
||||||
if (signers.isEmpty()) return null
|
|
||||||
|
|
||||||
// Try to unwrap the gift with each signer
|
|
||||||
for (signer in signers) {
|
|
||||||
try {
|
|
||||||
// TODO: custom unwrapping logic
|
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
|
||||||
val rumor = gift.rumor()
|
|
||||||
// Save the rumor to the database
|
|
||||||
setCachedRumor(event.id(), rumor)
|
|
||||||
// Return the rumor
|
|
||||||
return rumor
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to unwrap gift: ${e.message}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
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 =
|
|
||||||
if (metadata == RelayMetadata.READ) RelayCapabilities.read()
|
|
||||||
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
|
|
||||||
else RelayCapabilities.none()
|
|
||||||
)
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
return relayList
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
|
||||||
// Construct a list of messaging relays
|
|
||||||
val msgRelayList = listOf(
|
|
||||||
RelayUrl.parse("wss://relay.0xchat.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).signWithKeys(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).signWithKeys(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).signWithKeys(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = metadataEvent,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send contact list event
|
|
||||||
val defaultContact =
|
|
||||||
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
|
|
||||||
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = contactListEvent,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
setSigner(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
|
||||||
try {
|
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
val results = mutableMapOf<PublicKey, Metadata>()
|
|
||||||
|
|
||||||
events?.toVec()?.forEach { event ->
|
|
||||||
val metadata = Metadata.fromJson(event.content())
|
|
||||||
results[event.author()] = metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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://user.kindpag.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).signAsync(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 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 getChatRooms(): Set<Room>? {
|
|
||||||
try {
|
|
||||||
val userPubkey = signer.currentUser ?: 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).author(userPubkey).customTag(kTag, "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 filter =
|
|
||||||
Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList())
|
|
||||||
|
|
||||||
// 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()) }
|
|
||||||
?.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>): Map<PublicKey, List<RelayUrl>> {
|
|
||||||
try {
|
|
||||||
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
|
|
||||||
|
|
||||||
members.forEach { member ->
|
|
||||||
results[member] = mutableListOf<RelayUrl>()
|
|
||||||
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 = "room-${member.toBech32().substring(0, 10)}",
|
|
||||||
timeout = Duration.parse("3s"),
|
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
|
||||||
)
|
|
||||||
|
|
||||||
stream?.next()?.let { res ->
|
|
||||||
if (res.event != null) {
|
|
||||||
// Connect to the msg relays
|
|
||||||
connectMsgRelays(res.event!!)
|
|
||||||
// Mark the member as connected
|
|
||||||
results[member]?.add(res.relayUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} 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) {
|
|
||||||
if (client?.relay(url) == null) {
|
|
||||||
client?.addRelay(url)
|
|
||||||
client?.connectRelay(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendMessage(
|
|
||||||
to: List<PublicKey>,
|
|
||||||
content: String,
|
|
||||||
subject: String? = null,
|
|
||||||
replies: List<EventId> = emptyList(),
|
|
||||||
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val tags = mutableListOf<Tag>()
|
|
||||||
|
|
||||||
// Add a subject tag if provided
|
|
||||||
if (subject != null) {
|
|
||||||
tags.add(Tag.custom(TagKind.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 ->
|
|
||||||
if (pubkey != currentUser) {
|
|
||||||
tags.add(Tag.publicKey(pubkey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (receiver in listOf(currentUser) + to) {
|
|
||||||
// Construct the rumor event
|
|
||||||
// NEVER SIGN this event with the current user signer
|
|
||||||
val rumor = EventBuilder
|
|
||||||
.privateMsgRumor(receiver = receiver, message = content)
|
|
||||||
.tags(tags)
|
|
||||||
.build(currentUser)
|
|
||||||
// Ensure the event ID is set
|
|
||||||
.ensureId()
|
|
||||||
|
|
||||||
// Emit the rumor to the chat screen
|
|
||||||
if (receiver == currentUser) {
|
|
||||||
onRumorCreated?.invoke(rumor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the gift wrap event
|
|
||||||
val gift = giftWrapAsync(
|
|
||||||
signer = signer,
|
|
||||||
receiverPubkey = receiver,
|
|
||||||
rumor = rumor,
|
|
||||||
extraTags = listOf(
|
|
||||||
Tag.custom(TagKind.Unknown("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(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
|
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
|
||||||
target = target,
|
|
||||||
id = "search",
|
|
||||||
timeout = Duration.parse("4s"),
|
|
||||||
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,599 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
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.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.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
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
|
|
||||||
|
|
||||||
class NostrViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val secretStore: SecretStorage
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
|
||||||
val signerRequired = _signerRequired.asStateFlow()
|
|
||||||
|
|
||||||
private val _isCreating = MutableStateFlow(false)
|
|
||||||
val isCreating = _isCreating.asStateFlow()
|
|
||||||
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
|
||||||
|
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
|
|
||||||
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 {
|
|
||||||
// Check local stored secret (secret key or bunker)
|
|
||||||
login()
|
|
||||||
|
|
||||||
// Observe the signer state and verify the relay list
|
|
||||||
observeSignerAndCheckRelays()
|
|
||||||
|
|
||||||
// Get all local stored metadata
|
|
||||||
getCacheMetadata()
|
|
||||||
|
|
||||||
// Observe new events from the Nostr client
|
|
||||||
runObserver()
|
|
||||||
|
|
||||||
// Wait and merge metadata requests into a single batch
|
|
||||||
runMetadataBatching()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
// Ensure all relays are disconnect
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
nostr.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showError(message: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_errorEvents.send(message)
|
|
||||||
if (isCreating.value) _isCreating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runObserver() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// 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 metadata updates
|
|
||||||
launch {
|
|
||||||
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe contact list updates
|
|
||||||
launch {
|
|
||||||
nostr.contactListUpdates.collect { contacts ->
|
|
||||||
_contactList.value = contacts.toSet()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observes subscription close
|
|
||||||
launch {
|
|
||||||
nostr.subscriptionClosed.collect {
|
|
||||||
getChatRooms()
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun runMetadataBatching() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// 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) {
|
|
||||||
metadataRequestChannel.receive()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextKey != null) {
|
|
||||||
batch.add(nextKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
|
||||||
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 {
|
|
||||||
// Get user's signer secret
|
|
||||||
val secret = secretStore.get("user_signer")
|
|
||||||
|
|
||||||
// If no secret is found, show onboarding screen
|
|
||||||
if (secret == null) {
|
|
||||||
_signerRequired.value = true
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the empty secret state
|
|
||||||
_signerRequired.value = false
|
|
||||||
|
|
||||||
// Handle different signer types
|
|
||||||
if (secret.startsWith("nsec1")) {
|
|
||||||
val keys = Keys.parse(secret)
|
|
||||||
nostr.setSigner(keys)
|
|
||||||
} else if (secret.startsWith("bunker://")) {
|
|
||||||
try {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = Duration.parse("50s") // 50 seconds timeout
|
|
||||||
val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null)
|
|
||||||
nostr.setSigner(remote)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Invalid secret format: $secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()) {
|
|
||||||
_chatRooms.value = rooms
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all metadata for the current user
|
|
||||||
nostr.getUserMetadata()
|
|
||||||
|
|
||||||
// Small delay to ensure all relays are connected
|
|
||||||
delay(3000)
|
|
||||||
|
|
||||||
// Check if the relay list is empty
|
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
|
||||||
if (relays.isEmpty()) {
|
|
||||||
_isRelayListEmpty.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
var avatarUrl = ""
|
|
||||||
|
|
||||||
// Set loading state
|
|
||||||
_isCreating.value = true
|
|
||||||
|
|
||||||
// Upload picture to Blossom
|
|
||||||
if (picture != null) {
|
|
||||||
val blossom = BlossomClient(
|
|
||||||
url = "https://blossom.band",
|
|
||||||
client = HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
|
||||||
file = picture,
|
|
||||||
contentType = contentType,
|
|
||||||
signer = keys
|
|
||||||
)
|
|
||||||
|
|
||||||
avatarUrl = descriptor?.url ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create identity
|
|
||||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
|
||||||
|
|
||||||
// Save secret to the secret storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
|
|
||||||
// Set an empty secret state
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
|
||||||
if (secret.startsWith("nsec1")) {
|
|
||||||
val keys = Keys.parse(secret)
|
|
||||||
return keys.publicKey()
|
|
||||||
} else if (secret.startsWith("bunker://")) {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = Duration.parse("50s") // 50 seconds timeout
|
|
||||||
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
|
|
||||||
|
|
||||||
// Show toast to ask user to approve the connection
|
|
||||||
showError("Please approve the connection.")
|
|
||||||
|
|
||||||
return remote.getPublicKeyAsync()
|
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Invalid secret: $secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importIdentity(secret: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (secret.startsWith("nsec1")) {
|
|
||||||
val keys = Keys.parse(secret)
|
|
||||||
nostr.setSigner(keys)
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
// Set an empty secret state
|
|
||||||
_signerRequired.value = false
|
|
||||||
} else if (secret.startsWith("bunker://")) {
|
|
||||||
try {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = Duration.parse("50s") // 50 seconds timeout
|
|
||||||
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
|
|
||||||
nostr.setSigner(remote)
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
_signerRequired.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showError("Please enter a valid Secret or Bunker URI.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 currentUserMsgRelayList(): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
return nostr.getMsgRelays(nostr.signer.currentUser!!)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
.privateMsgRumor(to.first(), "")
|
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
|
||||||
.build(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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }
|
|
||||||
?: throw IllegalArgumentException("Room not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRooms() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val rooms = nostr.getChatRooms() ?: emptySet()
|
|
||||||
_chatRooms.update { currentRooms ->
|
|
||||||
val virtualRooms = currentRooms.filter { local ->
|
|
||||||
rooms.none { db -> db.id == local.id }
|
|
||||||
}
|
|
||||||
rooms + virtualRooms
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
|
||||||
try {
|
|
||||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
|
||||||
} 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
|
||||||
val room = getChatRoom(roomId)
|
|
||||||
val members = room.members
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
nostr.chatRoomConnect(members.toList())
|
|
||||||
}.getOrElse { e ->
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
members.associateWith { emptyList<RelayUrl>() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
nostr.sendMessage(
|
|
||||||
to = room.members.toList(),
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -75,7 +75,7 @@ class BlossomClient(
|
|||||||
signer: AsyncNostrSigner,
|
signer: AsyncNostrSigner,
|
||||||
authz: BlossomAuthorization
|
authz: BlossomAuthorization
|
||||||
): HeaderValue {
|
): HeaderValue {
|
||||||
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
|
val authEvent = EventBuilder.blossomAuth(authz).finalizeAsync(signer)
|
||||||
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
||||||
val value = "Nostr $encodedAuth"
|
val value = "Nostr $encodedAuth"
|
||||||
return HeaderValue(value)
|
return HeaderValue(value)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform interface for NIP-55 external signer communication.
|
||||||
|
* Implemented on Android; no-op/null on other platforms.
|
||||||
|
*/
|
||||||
|
interface ExternalSignerHandler {
|
||||||
|
fun isAvailable(): Boolean
|
||||||
|
fun setPackageName(packageName: String)
|
||||||
|
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
|
||||||
|
suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
|
||||||
|
suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
|
||||||
|
suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
|
||||||
|
suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
|
||||||
|
suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SignerPermission(
|
||||||
|
val type: String,
|
||||||
|
val kind: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object SignerPermissions {
|
||||||
|
fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
|
||||||
|
fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
|
||||||
|
fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
|
||||||
|
fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
|
||||||
|
fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
|
||||||
|
|
||||||
|
fun toJson(permissions: List<SignerPermission>): String {
|
||||||
|
return Json.encodeToString(permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExternalSignerResult(
|
||||||
|
val pubkey: PublicKey,
|
||||||
|
val packageName: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
|
||||||
|
class ExternalSignerProxy(
|
||||||
|
private val handler: ExternalSignerHandler,
|
||||||
|
private val currentUser: PublicKey,
|
||||||
|
) : AsyncNostrSigner {
|
||||||
|
override suspend fun getPublicKeyAsync(): PublicKey {
|
||||||
|
return currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
|
||||||
|
val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
|
||||||
|
return Event.fromJson(signedJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||||
|
return handler.nip04Encrypt(content, publicKey)
|
||||||
|
?: throw Exception("NIP-04 encrypt rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
|
||||||
|
return handler.nip04Decrypt(encryptedContent, publicKey)
|
||||||
|
?: throw Exception("NIP-04 decrypt rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||||
|
return handler.nip44Encrypt(content, publicKey, currentUser)
|
||||||
|
?: throw Exception("NIP-44 encrypt rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||||
|
return handler.nip44Decrypt(payload, publicKey, currentUser)
|
||||||
|
?: throw Exception("NIP-44 decrypt rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
336
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
336
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
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 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.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 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) }
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
265
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
265
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
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()
|
||||||
|
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.currentUser == 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.getMutualContacts(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,359 @@
|
|||||||
|
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.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 getMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "mutual-contacts",
|
||||||
|
closeOn = opts,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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() * 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
182
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
182
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
@@ -6,7 +6,6 @@ import kotlinx.datetime.minus
|
|||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.TagKind
|
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -37,14 +36,17 @@ data class Room(
|
|||||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||||
val id = rumor.roomId()
|
val id = rumor.roomId()
|
||||||
val createdAt = rumor.createdAt()
|
val createdAt = rumor.createdAt()
|
||||||
val subject = rumor.tags().find(TagKind.Subject)?.content()
|
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||||
|
|
||||||
// Collect the author's public key and all public keys from tags
|
// Collect the author's public key and all public keys from tags
|
||||||
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
||||||
pubkeys.add(rumor.author())
|
pubkeys.add(rumor.author())
|
||||||
pubkeys.addAll(rumor.tags().publicKeys())
|
pubkeys.addAll(rumor.tags().publicKeys())
|
||||||
|
|
||||||
// Also remove the user's public key from the list, current user is always a member
|
// Also remove the user's public key from the list, current user is always a member
|
||||||
|
if (pubkeys.size > 1 && pubkeys.contains(userPubkey)) {
|
||||||
pubkeys.remove(userPubkey)
|
pubkeys.remove(userPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new Room instance
|
// Create a new Room instance
|
||||||
return Room(
|
return Room(
|
||||||
@@ -1,32 +1,43 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.Event
|
import rust.nostr.sdk.Event
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var signer: AsyncNostrSigner = initialSigner
|
private var signer: AsyncNostrSigner = initialSigner
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var currentUser: PublicKey? = null
|
var currentUser: PublicKey? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current signer.
|
* Get the current signer.
|
||||||
*/
|
*/
|
||||||
suspend fun get(): AsyncNostrSigner = mutex.withLock {
|
fun get(): AsyncNostrSigner = signer
|
||||||
signer
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch to a new signer.
|
* Switch to a new signer.
|
||||||
*/
|
*/
|
||||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
||||||
|
val pubkey = try {
|
||||||
|
withTimeoutOrNull(20.seconds) {
|
||||||
|
newSigner.getPublicKeyAsync()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get public key from signer", e)
|
||||||
|
}
|
||||||
signer = newSigner
|
signer = newSigner
|
||||||
currentUser = newSigner.getPublicKeyAsync()
|
currentUser = pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package su.reya.coop.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
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.storage.SecretStorage
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class AccountViewModel(
|
||||||
|
val nostr: Nostr,
|
||||||
|
private val secretStore: SecretStorage,
|
||||||
|
private val appViewModel: AppViewModel,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||||
|
val signerRequired = _signerRequired.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Skip the splash screen if a user is already logged in
|
||||||
|
if (nostr.signer.currentUser != null) {
|
||||||
|
_signerRequired.value = false
|
||||||
|
}
|
||||||
|
// Check local stored secret
|
||||||
|
login()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
appViewModel.showError("Login failed: ${e.message}")
|
||||||
|
_signerRequired.value = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Login failed: ${e.message}")
|
||||||
|
_signerRequired.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
appViewModel.setBusy(true)
|
||||||
|
// Reset the nostr signer and prune the database
|
||||||
|
nostr.signer.switch(Keys.generate())
|
||||||
|
nostr.prune()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Logout encountered an error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
// Clear credentials from persistent storage
|
||||||
|
secretStore.clear("user_signer")
|
||||||
|
secretStore.clear("notification_banner_dismissed")
|
||||||
|
// Reset local states
|
||||||
|
appViewModel.setBusy(false)
|
||||||
|
_signerRequired.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
|
val secret = secretStore.get("app_keys")
|
||||||
|
if (secret != null) return Keys.parse(secret)
|
||||||
|
|
||||||
|
val keys = Keys.generate()
|
||||||
|
secretStore.set("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")
|
||||||
|
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://")) {
|
||||||
|
appViewModel.showError("Please approve the connection.")
|
||||||
|
}
|
||||||
|
return signer.getPublicKeyAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importIdentity(secret: String) {
|
||||||
|
appViewModel.setBusy(true)
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
secretStore.set("user_signer", secret)
|
||||||
|
_signerRequired.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
appViewModel.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = "image/jpeg",
|
||||||
|
profileViewModel: ProfileViewModel
|
||||||
|
) {
|
||||||
|
appViewModel.setBusy(true)
|
||||||
|
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let { profileViewModel.blossomUpload(it, contentType) }
|
||||||
|
nostr.profiles.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
||||||
|
// Set credentials in persistent storage
|
||||||
|
secretStore.set("user_signer", secret)
|
||||||
|
// Update local state
|
||||||
|
_signerRequired.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
appViewModel.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectExternalSigner() {
|
||||||
|
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
|
appViewModel.setBusy(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)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Set credentials in persistent storage
|
||||||
|
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
|
||||||
|
// Update local state
|
||||||
|
_signerRequired.value = false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Notice: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
appViewModel.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
|
return externalSignerHandler?.isAvailable() == true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package su.reya.coop.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.storage.SecretStorage
|
||||||
|
|
||||||
|
class AppViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val secretStore: SecretStorage
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _isBusy = MutableStateFlow(false)
|
||||||
|
val isBusy = _isBusy.asStateFlow()
|
||||||
|
|
||||||
|
private val _isNotificationBannerDismissed = MutableStateFlow(false)
|
||||||
|
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
|
||||||
|
|
||||||
|
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
||||||
|
val errorEvents = _errorEvents.receiveAsFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBusy(busy: Boolean) {
|
||||||
|
_isBusy.value = busy
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showError(message: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_errorEvents.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_isNotificationBannerDismissed.value =
|
||||||
|
secretStore.get("notification_banner_dismissed") == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
secretStore.set("notification_banner_dismissed", "true")
|
||||||
|
_isNotificationBannerDismissed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
nostr.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package su.reya.coop.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
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.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.nostr.Room
|
||||||
|
import su.reya.coop.nostr.roomId
|
||||||
|
|
||||||
|
class ChatViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val appViewModel: AppViewModel
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||||
|
val chatRooms = _chatRooms.asStateFlow()
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
||||||
|
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
||||||
|
|
||||||
|
val isSyncing = nostr.messages.messageSyncState.map { it.isSyncing }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
suspend fun bindObservers() = coroutineScope {
|
||||||
|
launch {
|
||||||
|
nostr.messages.messageSyncState.collect { state ->
|
||||||
|
if (state.processedCount > 0) {
|
||||||
|
_isPartialProcessedGiftWrap.value = true
|
||||||
|
}
|
||||||
|
if (state.processedCount % 10 == 0 || !state.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
nostr.signer.currentUser ?: throw IllegalStateException("User not signed in")
|
||||||
|
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||||
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
|
||||||
|
val id = rumor.roomId()
|
||||||
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return existingRoom.id
|
||||||
|
}
|
||||||
|
|
||||||
|
val room = Room.new(rumor, currentUser)
|
||||||
|
_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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRooms() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
mergeChatRooms(rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshChatRooms() {
|
||||||
|
try {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
mergeChatRooms(rooms)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mergeChatRooms(rooms: Set<Room>) {
|
||||||
|
_chatRooms.update { currentRooms ->
|
||||||
|
val merged = currentRooms.associateBy { it.id }.toMutableMap()
|
||||||
|
rooms.forEach { room ->
|
||||||
|
merged[room.id] = room
|
||||||
|
}
|
||||||
|
merged.values.sortedDescending().toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
return nostr.messages.getChatRoomMessages(roomId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRoomConnect(roomId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
nostr.messages.chatRoomConnect(room.members.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
appViewModel.showError("Message cannot be empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
return if (giftWrapId != null) {
|
||||||
|
nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
|
} else {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package su.reya.coop.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
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.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
class ProfileViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val appViewModel: AppViewModel
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
||||||
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun bindObservers() = coroutineScope {
|
||||||
|
launch {
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch { runMetadataBatching() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
metadataRequestChannel.send(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCacheMetadata() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
val results = nostr.profiles.getAllCacheMetadata()
|
||||||
|
results.forEach { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, metadata)
|
||||||
|
seenPublicKeys.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
val batch = mutableSetOf<PublicKey>()
|
||||||
|
val timeout = 500L
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
if (nextKey != null) batch.add(nextKey)
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
|
val keysToRequest = batch.toList()
|
||||||
|
batch.clear()
|
||||||
|
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun blossomUpload(file: ByteArray, contentType: String?): String? {
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
appViewModel.setBusy(true)
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
|
||||||
|
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
updateMetadata(currentUser, newMetadata)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
appViewModel.setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addContact(address: String): Boolean {
|
||||||
|
val pubkey = try {
|
||||||
|
if (address.contains("@")) {
|
||||||
|
nostr.profiles.searchByAddress(address)
|
||||||
|
} else {
|
||||||
|
PublicKey.parse(address)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Invalid contact address: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pubkey in contactList.value) return true
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val updated = contactList.value + pubkey
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
_contactList.update { it + pubkey }
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (publicKey !in contactList.value) return@launch
|
||||||
|
try {
|
||||||
|
val updated = contactList.value - publicKey
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByAddress(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByNostr(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyActivity(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyContact(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.mutualContacts(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
setOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUserMetadata() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.profiles.getUserMetadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package su.reya.coop.viewmodels
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class RelayViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val appViewModel: AppViewModel,
|
||||||
|
private val chatViewModel: ChatViewModel,
|
||||||
|
private val profileViewModel: ProfileViewModel
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _isRelayListEmpty = MutableStateFlow(false)
|
||||||
|
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
||||||
|
|
||||||
|
fun reconnect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observeSignerAndCheckRelays() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
val pubkey = nostr.signer.currentUser
|
||||||
|
if (pubkey != null) {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
if (rooms.isNotEmpty()) {
|
||||||
|
chatViewModel.mergeChatRooms(rooms)
|
||||||
|
// Note: isPartialProcessedGiftWrap is in ChatViewModel
|
||||||
|
// We might need to expose a way to set it or just let it be updated by observers
|
||||||
|
}
|
||||||
|
|
||||||
|
profileViewModel.getUserMetadata()
|
||||||
|
|
||||||
|
delay(2.seconds)
|
||||||
|
|
||||||
|
val relays = nostr.relays.getMsgRelays(pubkey)
|
||||||
|
if (relays.isEmpty()) _isRelayListEmpty.value = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(500.milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refetchMsgRelays(pubkey: PublicKey) {
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(pubkey)
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_isRelayListEmpty.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun useDefaultMsgRelayList() {
|
||||||
|
try {
|
||||||
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.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) {
|
||||||
|
appViewModel.showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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