47 Commits

Author SHA1 Message Date
f49279fd10 fix 2026-07-06 10:04:23 +07:00
a48c0c0e7c update 2026-07-06 09:26:45 +07:00
d23b15df6f refactor 2026-07-06 09:15:25 +07:00
8750eaa225 fix crash 2026-07-06 07:27:50 +07:00
0ad872d186 remove toast message 2026-07-06 07:20:43 +07:00
80c6426d27 chore: bump version 2026-07-04 09:30:30 +07:00
801347ccb1 feat: render image in chat message (#36)
Reviewed-on: #36
2026-07-04 02:25:09 +00:00
19daea119d chore: confirm before delete contact (#35)
Reviewed-on: #35
2026-07-03 09:09:56 +00:00
8b2f0faa59 feat: add support for simple speech to text (#34)
Reviewed-on: #34
2026-07-03 08:38:58 +00:00
a2d28ecc40 chore: refactor the internal structure (#33)
Reviewed-on: #33
2026-07-03 01:20:36 +00:00
cd5a393a01 feat: add basic support for file upload via blossom (#32)
Reviewed-on: #32
2026-06-30 08:11:19 +00:00
3bfe2308ba chore: bump version 2026-06-30 07:28:21 +07:00
eed9d401da chore: re-structure project (#31)
Reviewed-on: #31
2026-06-29 13:42:56 +00:00
3943e2baab chore: remove force unwraps (#30)
Reviewed-on: #30
2026-06-28 02:06:57 +00:00
d5ea5570b6 chore: improve event syncing (#29)
Reviewed-on: #29
2026-06-28 01:28:25 +00:00
26f9e577f4 chore: bump version 2026-06-27 10:26:55 +07:00
dd416711e2 fix: app crash when message id is null (#28)
Reviewed-on: #28
2026-06-27 03:26:07 +00:00
b90279e174 chore: improve qr scan result handling (#27)
Reviewed-on: #27
2026-06-27 01:14:26 +00:00
25ba03562a chore: improve handling of loading states (#26)
Reviewed-on: #26
2026-06-26 09:23:03 +00:00
010a8b4060 feat: wipe database on logout (#25)
Reviewed-on: #25
2026-06-25 08:12:27 +00:00
750d46bc9e chore: update zapstore.yaml 2026-06-25 10:14:32 +07:00
790d24c24b chore: update metadata 2026-06-25 09:53:38 +07:00
4fbc8446d3 fix: release source endpoint 2026-06-25 09:26:15 +07:00
b4518ce360 chore: add app screenshots 2026-06-25 09:19:16 +07:00
0f4bb71a39 chore: prepare to publish 2026-06-24 17:36:04 +07:00
37003e2fc4 chore: bump version 2026-06-20 13:28:39 +07:00
154906017e feat: screener for message requests (#24)
Reviewed-on: #24
2026-06-19 09:06:49 +00:00
91e4e3b43d feat: support grouped new chat requests (#23)
Reviewed-on: #23
2026-06-18 00:34:23 +00:00
ea90a43909 feat: add contact list screen (#22)
Reviewed-on: #22
2026-06-15 07:38:53 +00:00
938b192136 fix: foreground service crashing (#21)
Reviewed-on: #21
2026-06-13 02:12:16 +00:00
1f966de654 chore: bump version 2026-06-12 15:50:14 +07:00
00821a864b fix: nostr operations cause app crashing (#20)
Reviewed-on: #20
2026-06-12 08:49:14 +00:00
28550f8e25 feat: Relay Management (#19)
Reviewed-on: #19
2026-06-11 10:40:36 +00:00
a759ad48e4 chore: bump version 2026-06-09 14:55:47 +07:00
0d6b92b0c7 feat: Add support for NIP-55 (#18)
Reviewed-on: #18
2026-06-09 07:53:48 +00:00
6a69d3a5b2 chore: update nostr sdk 2026-06-08 16:40:56 +07:00
50b7f7a3f3 chore: bump version 2026-06-07 07:37:30 +07:00
a65aa70a55 chore: optimize the battery usage (#16)
Reviewed-on: #16
2026-06-07 00:35:46 +00:00
74a37320fe fix: occasional splash screen hang (#15)
Reviewed-on: #15
2026-06-06 07:30:47 +00:00
b8b3b83952 feat: add update profile screen (#14)
Reviewed-on: #14
2026-06-06 05:50:32 +00:00
5c2115e8b7 chore: bump version 2026-06-04 09:06:00 +07:00
ec337b8756 feat: add self-chat (#13)
Reviewed-on: #13
2026-06-04 01:59:38 +00:00
fcae7d5825 fix: crash when getting all cached metadata (#12)
Reviewed-on: #12
2026-06-03 07:50:34 +00:00
1e90b8d4b1 chore: bump version 2026-06-03 08:35:28 +07:00
71a8240b1d feat: add notification permission banner (#11)
Reviewed-on: #11
2026-06-02 08:54:47 +00:00
ff383a7c6a feat: add crash screen (#10)
Reviewed-on: #10
2026-06-02 02:19:13 +00:00
15e8c984e2 fix: app doesn't navigate to home screen after create or import identity (#9)
Reviewed-on: #9
2026-06-01 13:47:15 +00:00
68 changed files with 5838 additions and 2643 deletions

93
CHANGELOG.md Normal file
View File

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

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
assets/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

BIN
assets/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

BIN
assets/screenshot3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
assets/screenshot4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
assets/screenshot5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -24,9 +24,9 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
implementation("su.reya:nostr-sdk-kmp:0.3.2")
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("io.github.alexzhirkevich:qrose:1.1.2")
}
@@ -69,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.4"
versionName = "0.2.3"
}
packaging {
resources {

View File

@@ -8,9 +8,17 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
</queries>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -19,10 +27,17 @@
android:supportsRtl="true"
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
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.App.Starting">
android:theme="@style/Theme.App.Starting"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -45,7 +60,7 @@
android:name=".NostrForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="remoteMessaging" />
</application>
</manifest>

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
</vector>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,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>

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>

View File

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

View File

@@ -2,50 +2,31 @@ 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.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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.expressiveLightColorScheme
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.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.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@@ -53,8 +34,9 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.screens.ChatScreen
import su.reya.coop.repository.ErrorRepository
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.screens.ContactListScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
import su.reya.coop.screens.MyQrScreen
@@ -63,12 +45,25 @@ import su.reya.coop.screens.NewIdentityScreen
import su.reya.coop.screens.OnboardingScreen
import su.reya.coop.screens.ProfileScreen
import su.reya.coop.screens.RelayScreen
import su.reya.coop.screens.RequestListScreen
import su.reya.coop.screens.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
}
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
error("No ChatViewModel provided")
}
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
error("No AuthViewModel provided")
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
@@ -83,17 +78,20 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun App(viewModel: NostrViewModel) {
fun App(
nostrViewModel: NostrViewModel,
chatViewModel: ChatViewModel,
authViewModel: AuthViewModel,
) {
val context = LocalContext.current
val activity = context as? ComponentActivity
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val backStack = rememberNavBackStack(Screen.Home)
val navigator = remember(backStack) { Navigator(backStack) }
val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
// Get the signer required state
val authState by authViewModel.state.collectAsStateWithLifecycle()
val signerRequired = authState.signerRequired
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
@@ -104,7 +102,7 @@ fun App(viewModel: NostrViewModel) {
// Enabled the dynamic color scheme
val colorScheme = when {
// 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 (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
@@ -120,7 +118,7 @@ fun App(viewModel: NostrViewModel) {
}
LaunchedEffect(Unit) {
viewModel.errorEvents.collect { message ->
ErrorRepository.errors.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
@@ -163,11 +161,14 @@ fun App(viewModel: NostrViewModel) {
motionScheme = MotionScheme.expressive(),
) {
CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalNostrViewModel provides nostrViewModel,
LocalChatViewModel provides chatViewModel,
LocalAuthViewModel provides authViewModel,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) {
NavDisplay(
backStack = backStack,
onBack = {
@@ -185,32 +186,20 @@ fun App(viewModel: NostrViewModel) {
entry<Screen.Home> {
HomeScreen()
}
entry<Screen.RequestList> {
RequestListScreen()
}
entry<Screen.Onboarding> {
OnboardingScreen()
}
entry<Screen.Import> {
ImportScreen(
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
ImportScreen()
}
entry<Screen.NewIdentity> {
NewIdentityScreen(
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)
}
)
NewIdentityScreen()
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id)
ChatScreen(id = key.id, screening = key.screening)
}
entry<Screen.NewChat> {
NewChatScreen()
@@ -218,72 +207,23 @@ fun App(viewModel: NostrViewModel) {
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()
}
}
)
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { viewModel.dismissRelayWarning() },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messaging Relays are required",
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
style = MaterialTheme.typography.bodyLarge.copy(
fontStyle = FontStyle.Italic,
),
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
scope.launch {
viewModel.useDefaultMsgRelayList()
sheetState.hide()
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
}

View File

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

View File

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

View File

@@ -1,51 +1,106 @@
package su.reya.coop
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.nostr.NostrManager
import su.reya.coop.viewmodel.AuthViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.NostrViewModel
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
private val viewModel: NostrViewModel by viewModels {
companion object {
val externalSignerLauncher = ExternalSignerLauncher()
}
private val factory by lazy {
object : ViewModelProvider.Factory {
private val androidSigner =
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
private val secretStore = SecretStore(this@MainActivity)
private val nostrViewModel =
NostrViewModel(NostrManager.instance)
private val chatViewModel =
ChatViewModel(NostrManager.instance)
private val authViewModel =
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity)
return NostrViewModel(NostrManager.instance, secretStore) as T
return when {
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
else -> throw IllegalArgumentException("Unknown ViewModel class")
} as T
}
}
}
private val nostrViewModel: NostrViewModel by viewModels { factory }
private val chatViewModel: ChatViewModel by viewModels { factory }
private val authViewModel: AuthViewModel by viewModels { factory }
override fun onCreate(savedInstanceState: Bundle?) {
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()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val serviceIntent = Intent(this, NostrForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
startForegroundService(serviceIntent)
// Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null
authViewModel.state.value.signerRequired == null
}
setContent {
App(viewModel = viewModel)
App(
nostrViewModel = nostrViewModel,
chatViewModel = chatViewModel,
authViewModel = authViewModel,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)

View File

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

View File

@@ -6,56 +6,77 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import su.reya.coop.nostr.NostrManager
import java.io.File
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val nostr = NostrManager.instance
private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
private fun isUserInApp(): Boolean {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startAsForeground()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
if (intent?.action == "STOP_SERVICE") {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
val notification = createNotification()
startForeground(1, notification)
// Start the nostr service in the foreground
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 {
Log.d("Coop", "Starting Nostr in background")
// Create a database directory
val dbDir = File(filesDir, "nostr")
dbDir.mkdirs()
// Initialize Nostr client
nostr.init(dbDir.absolutePath)
try {
nostr.init(dbDir.absolutePath)
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e)
}
// Connect to bootstrap relays
nostr.connectBootstrapRelays()
// Handle notifications
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
},
onSubscriptionClose = {
serviceScope.launch { nostr.emitSubscriptionClosed() }
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
},
onNewMessage = { event ->
serviceScope.launch {
@@ -67,14 +88,33 @@ class NostrForegroundService : Service() {
}
)
} catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}")
Log.e("Coop", "Failed to start Nostr", e)
}
}
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() {
val manager = getSystemService(NotificationManager::class.java)
@@ -97,10 +137,12 @@ class NostrForegroundService : Service() {
private fun createNotification(content: String? = null): Notification {
val builder = NotificationCompat.Builder(this, "nostr_service")
.setGroup(GROUP_KEY_MESSAGES)
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
if (content != null) {
builder.setContentTitle("Coop")

View File

@@ -1,380 +0,0 @@
package su.reya.coop.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send
import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader
import su.reya.coop.roomId
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@Composable
fun ChatScreen(id: Long) {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState()
val chatRooms by viewModel.chatRooms.collectAsState()
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } }
if (room == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
return
}
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) }
val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) {
messages.groupBy { it.createdAt().formatAsGroupHeader() }
}
LaunchedEffect(id) {
// Start loading spinner
loading = true
// Get messages
val initialMessages = viewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
// 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
loading = false
// Handle new messages
viewModel.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
} else {
// If the event is not in the current room, it's a new message from another user
newOtherMessages++
}
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
room.members.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32()))
}
}
) {
if (loading) {
LoadingIndicator(
modifier = Modifier.size(32.dp),
)
} else {
Avatar(
picture = picture,
description = displayName,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
},
navigationIcon = {
BadgedBox(
badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
}
}
}
) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (messages.isNotEmpty()) {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
) {
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
items(
messagesInGroup,
key = { it.id()?.toBech32()!! }) { event ->
ChatMessage(event)
}
item {
DateSeparator(dateHeader)
}
}
}
} else {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(id, text)
text = ""
}
)
}
}
}
)
}
@Composable
fun DateSeparator(date: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = date,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
@Composable
fun ChatMessage(
rumor: UnsignedEvent
) {
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser()
val isMine = rumor.author() == currentUser
val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
}
val containerColor =
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
val contentColor =
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Surface(
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
modifier = Modifier
.widthIn(max = 280.dp)
.clickable(
onClick = {
val id = rumor.id()
if (id != null) {
val sent = viewModel.isMessageSent(id)
println("Sent: $sent")
}
}
)
) {
Text(
text = rumor.content(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit
) {
Surface(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Bottom
) {
TextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text("Message") },
shape = RoundedCornerShape(28.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton(
onClick = onSend,
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = painterResource(Res.drawable.ic_send),
contentDescription = "Send"
)
}
}
}
}

View File

@@ -0,0 +1,366 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_check
import coop.composeapp.generated.resources.ic_close
import coop.composeapp.generated.resources.ic_plus
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactListScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var openAddContactDialog by remember { mutableStateOf(false) }
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "My Contacts",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
}
)
},
floatingActionButton = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
tooltip = {
PlainTooltip { Text("New Contact") }
},
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = { openAddContactDialog = true },
expanded = false,
icon = {
Icon(
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "New Contact"
)
},
text = { Text("New Contact") },
)
}
},
content = { innerPadding ->
if (contactList.isNotEmpty()) {
val contacts = remember(contactList) { contactList.toList() }
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
itemsIndexed(contacts) { index, pubkey ->
ContactListItem(
pubkey = pubkey,
index = index,
total = contacts.size,
onClick = {
val room = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(room))
},
onLongClick = {
contactToDelete = pubkey
}
)
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No contacts yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your contacts will appear here",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
)
if (openAddContactDialog) {
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
}
if (contactToDelete != null) {
AlertDialog(
onDismissRequest = { contactToDelete = null },
title = { Text("Delete Contact") },
text = { Text("Are you sure you want to remove this contact?") },
confirmButton = {
TextButton(
onClick = {
contactToDelete?.let { nostrViewModel.removeContact(it) }
contactToDelete = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { contactToDelete = null }) {
Text("Cancel")
}
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AddContactDialog(onDismissRequest: () -> Unit) {
val snackbarHostState = LocalSnackbarHostState.current
val nostrViewModel = LocalNostrViewModel.current
val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onDismissRequest() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
decorFitsSystemWindows = false,
),
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
title = {
Text(
text = "New Contact",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = { onDismissRequest() }) {
Icon(
painter = painterResource(Res.drawable.ic_close),
contentDescription = "Close"
)
}
},
actions = {
IconButton(onClick = {
scope.launch {
val success = nostrViewModel.addContact(contact)
if (success) onDismissRequest()
}
}) {
Icon(
painter = painterResource(Res.drawable.ic_check),
contentDescription = "Add"
)
}
},
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = contact,
onValueChange = { contact = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
isError = isError,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
isError = contact.isNotEmpty() && !verifyContact(contact)
}
),
singleLine = true,
label = { Text(text = "Contact Address") },
placeholder = { Text(text = "npub1... or user@example.com") },
supportingText = {
if (isError) {
Text(text = "Contact address is invalid")
} else {
Text(text = "Only add contact you trust.")
}
},
)
}
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactListItem(
pubkey: PublicKey,
index: Int,
total: Int = 0,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
SegmentedListItem(
onClick = onClick,
onLongClick = onLongClick,
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = total
),
leadingContent = {
Avatar(
picture = profile?.picture,
description = profile?.name,
size = 36.dp
)
},
supportingContent = { Text(pubkey.short()) },
content = {
Text(
text = profile?.name ?: "No name",
style = MaterialTheme.typography.titleMedium,
)
}
)
}
fun verifyContact(address: String): Boolean {
return try {
if (address.contains("@")) Nip05Address.parse(address)
else PublicKey.parse(address)
true
} catch (e: Exception) {
println("Failed to parse contact: ${e.message}")
false
}
}

View File

@@ -1,20 +1,30 @@
package su.reya.coop.screens
import android.Manifest
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.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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -36,6 +46,7 @@ import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
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
@@ -49,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -61,53 +71,92 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ClipEntry
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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_close
import coop.composeapp.generated.resources.ic_new_chat
import coop.composeapp.generated.resources.ic_qr
import coop.composeapp.generated.resources.ic_request
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.Screen
import su.reya.coop.ago
import su.reya.coop.rememberUiState
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
val context = LocalContext.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.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 nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
val authState by authViewModel.state.collectAsStateWithLifecycle()
val isBannerDismissed = authState.isNotificationBannerDismissed
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
var isBusy by remember { mutableStateOf(false) }
var isNotificationEnabled by remember {
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
}
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) {
viewModel.getChatRooms()
chatViewModel.refreshChatRooms()
}
LaunchedEffect(qrScanResult.content) {
@@ -115,7 +164,7 @@ fun HomeScreen() {
runCatching { PublicKey.parse(result) }
.onSuccess { pubkey ->
try {
val roomId = viewModel.createChatRoom(listOf(pubkey))
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
@@ -136,10 +185,18 @@ fun HomeScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Text(
text = "Coop",
style = MaterialTheme.typography.titleMediumEmphasized
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Coop",
style = MaterialTheme.typography.titleMediumEmphasized
)
if (isSyncing) {
LoadingIndicator(modifier = Modifier.size(24.dp))
}
}
},
actions = {
// QR Scanner
@@ -152,8 +209,8 @@ fun HomeScreen() {
// User
IconButton(onClick = { showBottomSheet = true }) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
picture = userProfile?.picture,
description = userProfile?.name ?: "No name",
size = 32.dp,
)
}
@@ -187,180 +244,466 @@ fun HomeScreen() {
}
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
if (!isNotificationEnabled && !isBannerDismissed) {
Surface(
modifier = Modifier
.fillMaxWidth()
.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(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
text = "Get message notifications",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSecondaryFixed,
)
Text(
text = "Your conversations will appear here.",
text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { authViewModel.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")
}
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
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 {
sheetState.hide()
showBottomSheet = false
action()
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch {
pubkey?.let {
val bech32 = it.toBech32()
val data = ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
if (requests.isNotEmpty()) {
item { NewRequests(requests) }
}
items(ongoing, key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
}
},
)
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
}
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.picture,
description = userProfile?.name ?: "No name",
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userProfile?.name ?: "No name",
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch {
userProfile?.publicKey?.let {
val bech32 = it.toBech32()
val data = ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
},
) {
Text(text = userProfile?.shortPublicKey ?: "Unknown")
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { nostrViewModel.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 {
nostrViewModel.refetchMsgRelays()
} 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 {
nostrViewModel.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 nostrViewModel = LocalNostrViewModel.current
val total = requests.size
val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1)
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
val secondRoomState by (secondRoom ?: firstRoom).rememberUiState(nostrViewModel)
val supportingText = when {
total == 1 -> {
val message = firstRoom.lastMessage ?: ""
"${firstRoomState.name}: $message"
}
total == 2 -> {
"${firstRoomState.name} and ${secondRoomState.name}"
}
total > 2 -> {
val othersCount = total - 2
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
}
else -> ""
}
ListItem(
modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList)
},
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.clip(MaterialShapes.Clover4Leaf.toShape()),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_request),
contentDescription = "Requests",
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
}
}
},
headlineContent = {
Text(
text = "Requests",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
supportingContent = {
if (supportingText.isNotEmpty()) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surface
)
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatRoom(room: Room, onClick: () -> Unit) {
val viewModel = LocalNostrViewModel.current
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
val nostrViewModel = LocalNostrViewModel.current
val roomState by room.rememberUiState(nostrViewModel)
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = {
Avatar(picture = picture, description = displayName)
Avatar(picture = roomState.picture, description = roomState.picture)
},
headlineContent = {
Row(
@@ -368,7 +711,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = displayName,
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
modifier = Modifier.weight(1f)
)
@@ -382,8 +725,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
supportingContent = {
if (!room.lastMessage.isNullOrBlank()) {
Text(
text = room.lastMessage!!,
style = MaterialTheme.typography.bodyMedium
text = room.lastMessage ?: "",
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
@@ -399,12 +744,14 @@ fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit
) {
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val authViewModel = LocalAuthViewModel.current
val defaultMenuList = listOf(
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { navigator.navigate(Screen.ContactList) },
"Relay Management" to { navigator.navigate(Screen.Relay) },
"Spams & Blocks" to { },
"Contacts" to { },
"Settings" to { }
)
@@ -429,7 +776,14 @@ fun BottomMenuList(
}
Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton(
onClick = { viewModel.logout() },
onClick = {
onDismiss {
authViewModel.logout(onLogout = {
nostrViewModel.resetInternalState()
chatViewModel.resetInternalState()
})
}
},
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError

View File

@@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,68 +51,58 @@ import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportScreen(
onSave: (secret: String) -> Unit
) {
fun ImportScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val viewModel = LocalNostrViewModel.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
val metadata by remember(pubkey) {
if (pubkey != null) {
viewModel.getMetadata(pubkey!!)
} else {
MutableStateFlow(null)
}
}.collectAsState(null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
val isLoading by viewModel.isCreating.collectAsState()
var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching {
if (result.startsWith("nsec")) {
if (result.startsWith("nsec1")) {
Keys.parse(result)
} else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result)
} else {
throw IllegalArgumentException("Invalid secret: $result")
throw IllegalArgumentException("Unsupported secret format")
}
}.onSuccess {
secret = result
}.onFailure { e ->
e.message?.let { snackbarHostState.showSnackbar(it) }
}
.onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
qrScanResult.clear()
}
}
LaunchedEffect(secret) {
if (secret.startsWith("ncryptsec1")) {
requirePassword = true
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -166,21 +155,14 @@ fun ImportScreen(
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Cookie9Sided.toShape(),
Text(
text = "",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
}
Surface(
modifier = Modifier
@@ -192,7 +174,7 @@ fun ImportScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.padding(24.dp),
) {
Column(
modifier = Modifier
@@ -209,8 +191,9 @@ fun ImportScreen(
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !loading,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
@@ -221,10 +204,10 @@ fun ImportScreen(
),
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (secret.isEmpty()) {
@@ -242,28 +225,68 @@ fun ImportScreen(
}
}
)
Spacer(modifier = Modifier.size(8.dp))
if (requirePassword) {
Text(
text = "Decrypt Password:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = password,
onValueChange = { password = it },
enabled = !loading && requirePassword,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
innerTextField()
}
}
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
if (pubkey == null) {
scope.launch {
viewModel.verifyIdentity(secret).let { pubkey = it }
scope.launch {
loading = true
try {
// Import the identity
authViewModel.importIdentity(secret, password)
// Navigate to the home screen
navigator.navigate(Screen.Home)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
loading = false
}
} else {
onSave(secret)
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading,
enabled = secret.isNotBlank() && !loading,
) {
if (isLoading) {
if (loading) {
LoadingIndicator()
} else {
Text(
text = if (pubkey == null) "Verify" else "Continue",
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}

View File

@@ -13,8 +13,10 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
@@ -27,8 +29,8 @@ import su.reya.coop.LocalSnackbarHostState
fun MyQrScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return
val nostrViewModel = LocalNostrViewModel.current
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -59,7 +61,7 @@ fun MyQrScreen() {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = rememberQrCodePainter(currentUser.toBech32()),
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
contentDescription = "My QR"
)
}

View File

@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_arrow_next
@@ -54,6 +55,7 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
@@ -61,6 +63,7 @@ import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -68,17 +71,19 @@ fun NewChatScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val viewModel = LocalNostrViewModel.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
val createGroup = remember { mutableStateOf(false) }
val searchResults = remember { mutableStateListOf<PublicKey>() }
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") }
LaunchedEffect(query) {
if (query.length >= 3) {
delay(500) // 500ms debounce
delay(500.milliseconds)
if (query.startsWith("npub1")) {
val pubkey = try {
@@ -91,12 +96,12 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey)
}
} else if (query.contains("@")) {
val pubkey = viewModel.searchByAddress(query)
val pubkey = nostrViewModel.searchByAddress(query)
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
} else {
val results = viewModel.searchByNostr(query)
val results = nostrViewModel.searchByNostr(query)
searchResults.clear()
searchResults.addAll(results)
}
@@ -108,14 +113,13 @@ fun NewChatScreen() {
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
// Verify the content
runCatching { PublicKey.parse(result) }
.onSuccess { pubkey ->
selectedReceivers.add(pubkey)
}
.onFailure { e ->
println("Failed to parse QR: ${e.message}")
}
runCatching {
PublicKey.parse(result)
}.onSuccess { pubkey ->
selectedReceivers.add(pubkey)
}.onFailure { e ->
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
}
// Clear the nav state
qrScanResult.clear()
}
@@ -167,7 +171,7 @@ fun NewChatScreen() {
) {
ExtendedFloatingActionButton(
onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
navigator.navigate(Screen.Chat(roomId))
},
expanded = false,
@@ -258,7 +262,7 @@ fun NewChatScreen() {
items = searchResults,
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId))
},
)
@@ -269,7 +273,7 @@ fun NewChatScreen() {
items = contactList.toList(),
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId))
}
)
@@ -286,13 +290,9 @@ fun ReceiverChip(
pubkey: PublicKey,
onRemove: () -> Unit
) {
val viewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip(
@@ -300,7 +300,7 @@ fun ReceiverChip(
onClick = onRemove,
label = {
Text(
text = displayName,
text = profile?.name ?: "No name",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
@@ -308,8 +308,8 @@ fun ReceiverChip(
},
avatar = {
Avatar(
picture = picture,
description = displayName,
picture = profile?.picture,
description = profile?.name ?: "No name",
size = 24.dp
)
},
@@ -372,13 +372,9 @@ fun ContactListItem(
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val viewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
val nostrViewModel = LocalNostrViewModel.current
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
SegmentedListItem(
selected = isSelected,
@@ -390,15 +386,15 @@ fun ContactListItem(
),
leadingContent = {
Avatar(
picture = picture,
description = displayName,
picture = profile?.picture,
description = profile?.name ?: "",
size = 36.dp
)
},
supportingContent = { Text(text = pubkey.short()) },
content = {
Text(
text = displayName,
text = profile?.name ?: "",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}

View File

@@ -1,279 +1,27 @@
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.collectAsState
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.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 org.jetbrains.compose.resources.painterResource
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewIdentityScreen(
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
fun NewIdentityScreen() {
val authViewModel = LocalAuthViewModel.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
val isLoading by viewModel.isCreating.collectAsState()
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
picture = uri
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Create a new identity",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
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,
)
}
}
}
}
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
authViewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
}
)

View File

@@ -1,5 +1,6 @@
package su.reya.coop.screens
import android.content.Intent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
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.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.translate
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
@@ -34,9 +40,12 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalAuthViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
@@ -45,11 +54,15 @@ import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val authViewModel = LocalAuthViewModel.current
val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily()
val annotatedText = buildAnnotatedString {
append("By using Coop, you agree to accept\nour ")
// Push "Terms of Use" link
@@ -142,7 +155,47 @@ fun OnboardingScreen() {
)
}
Spacer(modifier = Modifier.size(8.dp))
OutlinedButton(
FilledTonalButton(
onClick = {
scope.launch {
if (authViewModel.isExternalSignerAvailable()) {
try {
// Connect to the external signer
// TODO: show all available signers?
authViewModel.connectExternalSigner()
// Navigate to the home screen
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
.fillMaxWidth()

View File

@@ -27,7 +27,6 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -37,6 +36,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_chat
@@ -44,6 +44,7 @@ import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
@@ -60,21 +61,22 @@ fun ProfileScreen(pubkey: String) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope()
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: "No name"
val nip05 = profile?.nip05 ?: pubkey.short()
val picture = profile?.picture
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
val metadata = profile?.metadata?.asRecord()
val nip05 = metadata?.nip05 ?: pubkey.short()
val picture = metadata?.picture
val details = remember(profile) {
listOf(
"Username:" to (profile?.name ?: "None"),
"Website:" to (profile?.website ?: "None"),
"Lightning Address:" to (profile?.lud16 ?: "None"),
"Username:" to (metadata?.name ?: "None"),
"Website:" to (metadata?.website ?: "None"),
"Lightning Address:" to (metadata?.lud16 ?: "None"),
)
}
@@ -83,7 +85,7 @@ fun ProfileScreen(pubkey: String) {
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { },
title = { /* empty */ },
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
@@ -132,7 +134,7 @@ fun ProfileScreen(pubkey: String) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = displayName,
text = profile?.name ?: "No name",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
@@ -158,7 +160,8 @@ fun ProfileScreen(pubkey: String) {
onClick = {
scope.launch {
try {
val roomId = viewModel.createChatRoom(listOf(pubkey))
val roomId =
chatViewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
@@ -176,7 +179,7 @@ fun ProfileScreen(pubkey: String) {
}
Text(
text = "Message",
style = MaterialTheme.typography.labelSmall
style = MaterialTheme.typography.labelMedium
)
}
Column(
@@ -237,4 +240,4 @@ fun ProfileScreen(pubkey: String) {
}
}
)
}
}

View File

@@ -3,34 +3,65 @@ 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.Row
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.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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
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.semantics.Role
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 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 kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
@@ -43,8 +74,9 @@ import su.reya.coop.LocalSnackbarHostState
fun RelayScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
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) {
relayList.putAll(viewModel.currentUserRelayList())
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
relayList.putAll(nostrViewModel.currentUserRelayList())
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
}
Scaffold(
@@ -86,9 +121,33 @@ fun RelayScreen() {
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 ->
Column(
modifier = Modifier
@@ -113,7 +172,8 @@ fun RelayScreen() {
if (msgRelayList.isNotEmpty()) {
msgRelayList.forEachIndexed { index, relayUrl ->
SegmentedListItem(
onClick = { },
onClick = { /* No action */ },
onLongClick = { relayToDelete = relayUrl.toString() },
shapes = ListItemDefaults.segmentedShapes(
index = index,
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 {
nostrViewModel.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 nostrViewModel = LocalNostrViewModel.current
val snackbarHostState = LocalSnackbarHostState.current
val scope = rememberCoroutineScope()
val focusRequester = remember { FocusRequester() }
var relayAddress by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
val roles = listOf("Messaging", "Inbox", "Outbox")
val (selected, onSelected) = remember { mutableStateOf(roles[0]) }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = { onDismissRequest() },
properties = DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
decorFitsSystemWindows = false,
),
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
title = {
Text(
text = "New Relay",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = { onDismissRequest() }) {
Icon(
painter = painterResource(Res.drawable.ic_close),
contentDescription = "Close"
)
}
},
actions = {
IconButton(onClick = {
scope.launch {
if (!isError) {
when (selected) {
"Messaging" -> {
nostrViewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
nostrViewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
nostrViewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
}
onDismissRequest()
}
}
}) {
Icon(
painter = painterResource(Res.drawable.ic_check),
contentDescription = "Add"
)
}
},
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = relayAddress,
onValueChange = { relayAddress = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
isError = isError,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
}
),
singleLine = true,
label = { Text(text = "Relay Address") },
placeholder = { Text(text = "wss://relay.example.com") },
supportingText = {
if (isError) {
Text(text = "Invalid format. Must start with wss://")
} else {
Text(text = "Only add relays you trust.")
}
},
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Relay Roles",
style = MaterialTheme.typography.titleMediumEmphasized
)
Column(
modifier = Modifier
.fillMaxWidth()
.selectableGroup(),
) {
roles.forEach { text ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.selectable(
onClick = { onSelected(text) },
selected = (text == selected),
role = Role.RadioButton
)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == selected),
onClick = null
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
)
}
}
fun verifyRelayUrl(url: String): Boolean {
return try {
RelayUrl.parse(url)
true
} catch (e: Exception) {
println("Failed to parse relay url: ${e.message}")
false
}
}

View File

@@ -0,0 +1,158 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.launch
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RequestListScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) }
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
// Get all request rooms
val requests = remember(chatRooms) {
chatRooms.filter { it.kind == RoomKind.Request }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Text(
text = "New Requests",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (requests.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No requests yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "New chat requests will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(requests.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id, true)) }
)
}
}
}
}
}
}
}
}

View File

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

View File

@@ -0,0 +1,37 @@
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.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor
@Composable
fun UpdateProfileScreen() {
val nostrViewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.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 {
nostrViewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
}
)
}

View File

@@ -0,0 +1,177 @@
package su.reya.coop.screens.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_cancel
import coop.composeapp.generated.resources.ic_check_circle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.Room
import su.reya.coop.humanReadable
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ScreenerCard(room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val nostrViewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var isContact by remember { mutableStateOf(false) }
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {
scope.launch {
// Check contact
nostrViewModel.verifyContact(pubkey).let { isContact = it }
// Get mutual contacts
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
// Get the last activity
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 48.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
picture = profile?.picture,
description = "Profile picture",
modifier = Modifier.size(120.dp),
shape = MaterialShapes.Cookie12Sided.toShape(),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = profile?.name ?: "No name",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
Text(
text = pubkey.short(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
),
contentDescription = "Warning",
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Text(
text = if (isContact) "Contact" else "Not a contact",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
),
contentDescription = "Warning",
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Text(
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_check_circle),
contentDescription = "Warning",
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
Text(
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
}
}
}
@Composable
fun DateSeparator(date: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = date,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}

View File

@@ -0,0 +1,97 @@
package su.reya.coop.screens.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_add_circle
import coop.composeapp.generated.resources.ic_audio
import coop.composeapp.generated.resources.ic_send
import org.jetbrains.compose.resources.painterResource
@Composable
fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit,
onUpload: () -> Unit,
onMicClick: () -> Unit
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom
) {
TextField(
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(28.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
value = value,
onValueChange = onValueChange,
placeholder = { Text("Message") },
leadingIcon = {
IconButton(onClick = onUpload) {
Icon(
painter = painterResource(Res.drawable.ic_add_circle),
contentDescription = "Upload",
)
}
},
)
Spacer(modifier = Modifier.size(8.dp))
AnimatedContent(
targetState = value.isNotEmpty(),
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
label = "send_mic_transition"
) { isNotEmpty ->
if (isNotEmpty) {
IconButton(
onClick = onSend,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = painterResource(Res.drawable.ic_send),
contentDescription = "Send"
)
}
} else {
FilledTonalIconButton(
onClick = onMicClick,
modifier = Modifier.size(56.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_audio),
contentDescription = "Speech to Text"
)
}
}
}
}
}

View File

@@ -0,0 +1,183 @@
package su.reya.coop.screens.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.URL_REGEX
import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls
@Immutable
data class MessageUiModel(
val id: String,
val annotatedContent: AnnotatedString,
val images: List<String>,
val timestamp: String,
val isMine: Boolean
)
@Composable
fun rememberMessageUiModel(
event: UnsignedEvent,
currentUserPublicKey: PublicKey?,
contentColor: Color
): MessageUiModel {
return remember(event, currentUserPublicKey, contentColor) {
val content = event.content()
val images = URL_REGEX.findAll(content)
.map { it.value }
.filter { it.isImageUrl() }
.toList()
val cleanedContent = content.removeImageUrls()
val annotatedString = buildAnnotatedString {
var lastIndex = 0
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
append(cleanedContent.substring(lastIndex, matchResult.range.first))
val url = matchResult.value
pushLink(
LinkAnnotation.Url(
url = url,
styles = TextLinkStyles(
style = SpanStyle(
color = contentColor,
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium
)
)
)
)
append(url)
pop()
lastIndex = matchResult.range.last + 1
}
append(cleanedContent.substring(lastIndex))
}
MessageUiModel(
id = event.id()?.toHex() ?: event.hashCode().toString(),
annotatedContent = annotatedString,
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = event.author() == currentUserPublicKey
)
}
}
@Composable
fun ChatMessage(model: MessageUiModel) {
var isMessageClicked by remember { mutableStateOf(false) }
val bubbleShape = if (model.isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
} else {
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
}
val containerColor =
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
val contentColor =
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
isMessageClicked = !isMessageClicked
},
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if (model.annotatedContent.isNotBlank()) {
Surface(
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
modifier = Modifier.widthIn(max = 280.dp)
) {
Text(
text = model.annotatedContent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
model.images.forEach { imageUrl ->
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 280.dp)
) {
AsyncImage(
model = imageUrl,
contentDescription = "Image from chat",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.FillWidth
)
}
}
AnimatedVisibility(
visible = isMessageClicked,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text(
text = model.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.align(
if (model.isMine) Alignment.End else Alignment.Start
)
)
}
}
}
}

View File

@@ -0,0 +1,385 @@
package su.reya.coop.screens.chat
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.speech.RecognizerIntent
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalChatViewModel
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen
import su.reya.coop.formatAsGroupHeader
import su.reya.coop.rememberUiState
import su.reya.coop.roomId
import su.reya.coop.shared.Avatar
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatScreen(id: Long, screening: Boolean = false) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val nostrViewModel = LocalNostrViewModel.current
val chatViewModel = LocalChatViewModel.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// Get current user
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
// Get chat room by ID
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen
if (room == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
}
return
}
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) }
var requireScreening by remember { mutableStateOf(screening) }
val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages =
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
val sendFile = { uri: Uri ->
scope.launch {
try {
// Read file
val file = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message
chatViewModel.sendFileMessage(id, file, type)
} catch (e: Exception) {
snackbarHostState.showSnackbar("Error: ${e.message}")
}
}
}
val fileLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { sendFile(it) }
}
val sttLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
val results = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
if (!results.isNullOrEmpty()) text = results[0]
}
}
LaunchedEffect(id) {
// Get messages
val initialMessages = chatViewModel.getChatRoomMessages(id)
messages.clear()
messages.addAll(initialMessages)
// Stop loading spinner
loading = false
// Get msg relays for each member
chatViewModel.chatRoomConnect(id)
// Handle new messages
chatViewModel.newEvents.collect { event ->
if (event.roomId() == id) {
if (event.id() !in messages.map { it.id() }) {
messages.add(0, event)
}
} else {
// If the event is not in the current room, it's a new message from another user
newOtherMessages++
}
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable {
room?.members?.firstOrNull()?.let { pubkey ->
navigator.navigate(Screen.Profile(pubkey.toBech32()))
}
}
) {
if (loading) {
LoadingIndicator(modifier = Modifier.size(32.dp))
} else {
Avatar(
picture = roomState.picture,
description = roomState.name,
size = 32.dp,
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
},
navigationIcon = {
BadgedBox(
badge = {
if (newOtherMessages > 0) {
Badge {
Text(newOtherMessages.toString())
}
}
}
) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
if (requireScreening) {
room?.let { ScreenerCard(it) }
}
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
val otherColor = MaterialTheme.colorScheme.onSurface
when (messages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true,
state = listState,
) {
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items(
items = messagesInGroup,
key = { it.id()?.toHex() ?: it.hashCode().toString() }
) { event ->
val isMine = currentUser?.publicKey == event.author()
val uiModel = rememberMessageUiModel(
event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
)
ChatMessage(model = uiModel)
}
item {
DateSeparator(dateHeader)
}
}
}
}
false -> {
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
when (requireScreening) {
true -> {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { navigator.goBack() },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Reject",
style = MaterialTheme.typography.titleMedium,
)
}
FilledTonalButton(
onClick = { requireScreening = false },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Accept",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
else -> {
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
chatViewModel.sendMessage(id, text)
text = ""
},
onUpload = {
fileLauncher.launch("image/*")
},
onMicClick = {
val intent =
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
}
try {
sttLauncher.launch(intent)
} catch (e: Exception) {
scope.launch {
snackbarHostState.showSnackbar("Speech recognition not available")
}
}
}
)
}
}
}
}
}
)
}

View File

@@ -0,0 +1,301 @@
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.rememberCoroutineScope
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.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)
onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
var isBusy by remember { mutableStateOf(false) }
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 = {
scope.launch {
isBusy = true
try {
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)
} catch (e: Exception) {
snackbarHostState.showSnackbar(e.message ?: "Error")
}
isBusy = false
}
},
enabled = name.isNotBlank() && !isBusy
) {
if (isBusy) {
LoadingIndicator()
} else {
Text(
text = buttonLabel,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
}

View File

@@ -1,33 +0,0 @@
package su.reya.coop.shared
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import su.reya.coop.NostrViewModel
import su.reya.coop.Room
import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
val memberFlows = members.map { viewModel.getMetadata(it) }
return combine(memberFlows) { metadataArray ->
if (isGroup()) {
val profiles = metadataArray.map { it?.asRecord() }
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
var combined = names.joinToString(", ")
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
combined.ifBlank { "Unknown group" }
} else {
val profile = metadataArray.firstOrNull()?.asRecord()
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
}
}
}
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}

View File

@@ -1,8 +1,8 @@
[versions]
agp = "9.2.1"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
android-compileSdk = "37"
android-minSdk = "26"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
@@ -10,14 +10,14 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-testExt = "1.3.0"
androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0"
composeMultiplatform = "1.11.1"
datastorePreferences = "1.2.1"
junit = "4.13.2"
kotlin = "2.3.21"
kotlin = "2.4.0"
kotlinx-serialization = "1.11.0"
material3 = "1.11.0-alpha07"
multiplatform-nav3-ui = "1.1.1"
ktor = "3.5.0"
ktor = "3.5.1"
[libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }

View File

@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
kotlin("plugin.serialization") version libs.versions.kotlin.get()
}
@@ -30,10 +32,11 @@ kotlin {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.3")
implementation("com.squareup.okio:okio:3.16.2")
implementation("su.reya:nostr-sdk-kmp:0.3.2")
implementation("com.squareup.okio:okio:3.17.0")
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)

View File

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

View File

@@ -0,0 +1,26 @@
package su.reya.coop
import rust.nostr.sdk.PublicKey
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun String.extractUrls(): List<String> {
return URL_REGEX.findAll(this).map { it.value }.toList()
}
fun String.removeImageUrls(): String {
return URL_REGEX.replace(this) { result ->
if (result.value.isImageUrl()) "" else result.value
}.replace(Regex("\\s+"), " ").trim()
}
fun String.isImageUrl(): Boolean {
val extension = this.substringAfterLast('.', "").lowercase()
return extension in imageExtensions
}

View File

@@ -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, "14")
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)
}
}
}

View File

@@ -1,585 +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.AsyncNostrSigner
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.Companion.seconds
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 {
try {
val secret = secretStore.get("user_signer")
if (secret == null) {
_signerRequired.value = true
return@launch
}
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_signerRequired.value = false
}.onFailure { e ->
showError("Login failed: ${e.message}")
_signerRequired.value = true
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_signerRequired.value = true
}
}
}
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
while (true) {
val pubkey = nostr.signer.currentUser
if (pubkey != null) {
// Get chat rooms
val rooms = nostr.getChatRooms() ?: emptySet()
if (rooms.isNotEmpty()) {
_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
}
private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds // or Duration.parse("50s")
NostrConnect(uri = bunker, appKeys, timeout, null)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
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? {
return runCatching {
val signer = createSigner(secret)
if (secret.startsWith("bunker://")) {
showError("Please approve the connection.")
}
signer.getPublicKeyAsync()
}.getOrNull()
}
fun importIdentity(secret: String) {
viewModelScope.launch {
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
secretStore.set("user_signer", secret)
}.onSuccess {
_signerRequired.value = false
}.onFailure { e ->
showError(e.message ?: "Invalid 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)
}

View File

@@ -0,0 +1,20 @@
package su.reya.coop
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.PublicKey
data class Profile(
val publicKey: PublicKey,
val metadata: Metadata
) {
private val record by lazy { metadata.asRecord() }
val name: String
get() = record.displayName ?: record.name ?: publicKey.short()
val picture: String?
get() = record.picture
val shortPublicKey: String
get() = publicKey.short()
}

View File

@@ -1,14 +1,21 @@
package su.reya.coop
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.TagKind
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.viewmodel.NostrViewModel
import kotlin.time.Clock
import kotlin.time.Instant
@@ -37,7 +44,7 @@ data class Room(
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId()
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
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
@@ -76,9 +83,60 @@ data class Room(
return this.copy(lastMessage = message)
}
fun isGroup(): Boolean {
return members.size > 1
fun isGroup(): Boolean = members.size > 1
}
data class RoomUiState(
val name: String = "Loading...",
val picture: String? = null,
val isGroup: Boolean = false
)
fun Room.uiStateFlow(
nostrViewModel: NostrViewModel,
currentUser: PublicKey? = null
): Flow<RoomUiState> {
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (!subject.isNullOrBlank()) {
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
}
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
val name = when {
isGroup() -> {
val combined = names.joinToString(", ")
val extra = members.size - names.size
if (extra > 0) "$combined, +$extra" else combined
}
else -> {
val first = names.firstOrNull() ?: "Unknown"
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
}
}
RoomUiState(
name = name,
picture = profiles.firstOrNull()?.picture,
isGroup = isGroup()
)
}
}
@Composable
fun Room.rememberUiState(
viewModel: NostrViewModel,
currentUser: PublicKey? = null
): State<RoomUiState> {
return remember(this, currentUser) {
uiStateFlow(
viewModel,
currentUser
)
}.collectAsStateWithLifecycle(RoomUiState())
}
fun UnsignedEvent.roomId(): Long {
@@ -95,21 +153,25 @@ fun UnsignedEvent.roomId(): Long {
return sortedUniqueKeys.hashCode().toLong()
}
fun Timestamp.ago(): String {
val SECONDS_IN_MINUTE = 60L
val MINUTES_IN_HOUR = 60L
val HOURS_IN_DAY = 24L
val DAYS_IN_MONTH = 30L
fun Timestamp.formatAsTime(): String {
val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
val hour = inputDateTime.hour.toString().padStart(2, '0')
val minute = inputDateTime.minute.toString().padStart(2, '0')
return "$hour:$minute"
}
fun Timestamp.ago(): String {
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val now = Clock.System.now()
val duration = now - inputInstant
return when {
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
duration.inWholeSeconds < 60L -> "Now"
duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m"
duration.inWholeHours < 24L -> "${duration.inWholeHours}h"
duration.inWholeDays < 30L -> "${duration.inWholeDays}d"
else -> {
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
val month =

View File

@@ -75,7 +75,7 @@ class BlossomClient(
signer: AsyncNostrSigner,
authz: BlossomAuthorization
): HeaderValue {
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
val authEvent = EventBuilder.blossomAuth(authz).finalizeAsync(signer)
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
val value = "Nostr $encodedAuth"
return HeaderValue(value)

View File

@@ -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,
)

View File

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

View File

@@ -0,0 +1,341 @@
package su.reya.coop.nostr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet
import rust.nostr.sdk.Client
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SingleLetterTag
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.nip17ExtractRelayList
import rust.nostr.sdk.nip59MakeGiftWrapAsync
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.roomId
import kotlin.time.Duration
data class MessageSyncState(
val processedCount: Int = 0,
val isSyncing: Boolean = false
)
class MessageManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private val _messageSyncState = MutableStateFlow(MessageSyncState())
val messageSyncState = _messageSyncState.asStateFlow()
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
_messageSyncState.update(update)
}
suspend fun getUserMessages(msgRelayList: Event) {
try {
val author =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val relays = nip17ExtractRelayList(msgRelayList)
// Ensure relay connections
relays.forEach { relay ->
client?.addRelay(relay)
client?.connectRelay(relay)
}
// Construct a filter for gift wrap events
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
val target = mutableMapOf<RelayUrl, List<Filter>>()
relays.forEach { relay ->
target[relay] = listOf(filter)
}
client?.subscribe(
target = ReqTarget.manual(target),
id = "gift-wraps"
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
}
}
suspend fun extractRumor(event: Event): UnsignedEvent? {
try {
// Gift wrap must have at least one 'p' tag
if (event.tags().publicKeys().isEmpty()) {
println("No recipient tags found.")
return null
}
// Event must be a gift wrap
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
println("Event is not a gift wrap.")
return null
}
// Check if the rumor is already cached
val cachedRumor = getCachedRumor(event.id())
if (cachedRumor != null) return cachedRumor
// Decrypt the gift wrap event
val seal = signer.nip44DecryptAsync(event.author(), event.content())
val sealEvent = Event.fromJson(seal)
// Verify seal event
if (!sealEvent.verify()) {
println("Failed to verify seal event.")
return null
}
// Decrypt the rumor
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
// Ensure the rumor author matches the seal
if (unsignedEvent.author() != sealEvent.author()) {
println("Author mismatch.")
return null
}
// Cache the rumor for later use
setCachedRumor(event.id(), unsignedEvent)
return unsignedEvent
} catch (e: Throwable) {
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
return null
}
}
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
try {
val filter = Filter().identifier(giftId.toHex())
val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
} catch (e: Throwable) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
}
}
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.publicKey(rumor.author()),
Tag.custom("r", listOf(rumor.roomId().toString())),
Tag.custom("k", listOf("14"))
)
// Set event kind
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
// Construct event
val event = EventBuilder(kind, rumor.asJson())
.tags(tags)
.finalizeAsync(Keys.generate())
client?.database()?.saveEvent(event)
} catch (e: Throwable) {
println("Failed to set cached rumor: ${e.message}")
}
}
suspend fun getChatRooms(): Set<Room>? {
try {
val userPubkey =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all DM events
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter)
// Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()) }
?.filter { it.tags().publicKeys().isNotEmpty() }
?.forEach { event ->
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
val existingRoom = roomsMap[newRoom.id]
// Check if the room already exists
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
val rTag = SingleLetterTag.lowercase(Alphabet.R)
val filter = Filter().kind(kind).pubkey(userPubkey)
.customTag(rTag, newRoom.id.toString())
// Determine if it's an ongoing room
val isOngoing =
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
// Append room to map
roomsMap[newRoom.id] =
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
}
}
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
return null
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
val filter = Filter().kind(kind).reference(roomId.toString())
val events = client?.database()?.query(filter)
// Merge the events
return events
?.toVec()
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
// Filter out events without public keys (receivers)
?.filter { it.tags().publicKeys().isNotEmpty() }
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
} catch (e: Exception) {
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
}
}
suspend fun chatRoomConnect(members: List<PublicKey>) {
try {
members.forEach { member ->
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(member).limit(1u)
val stream = client?.streamEvents(
target = ReqTarget.auto(listOf(filter)),
id = null,
timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose
)
stream?.next()?.let { res ->
val event = res.event ?: return@let
connectMsgRelays(event)
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
}
}
suspend fun connectMsgRelays(event: Event) {
try {
val urls = nip17ExtractRelayList(event)
for (url in urls) {
client?.addRelay(url, RelayCapabilities.gossip())
client?.connectRelay(url)
}
} catch (e: Exception) {
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
}
}
suspend fun sendMessage(
to: Set<PublicKey>,
content: String,
subject: String? = null,
replies: List<EventId> = emptyList(),
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
// Add a subject tag if provided
if (subject != null) {
tags.add(Tag.custom("subject", listOf(subject)))
}
// Add event tags for replies
if (replies.isNotEmpty()) {
replies.forEach { replyId ->
tags.add(Tag.event(replyId))
}
}
// Add public key tags for each recipient
to.forEach { pubkey ->
tags.add(Tag.publicKey(pubkey))
}
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
// NEVER SIGN this event with the current user signer
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
.tags(tags)
.finalizeUnsigned(currentUser)
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom("k", listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of sent events
sentEvents[output.id] = emptyList()
// Keep track of rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id
// Collect failed outputs
output.failed.forEach { (relayUrl, reason) ->
println("Failed to send event to relay $relayUrl: $reason")
}
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,267 @@
package su.reya.coop.nostr
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder
import rust.nostr.sdk.ClientNotification
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventId
import rust.nostr.sdk.GossipConfig
import rust.nostr.sdk.Keys
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.LogLevel
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrDatabase
import rust.nostr.sdk.NostrGossip
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMessageEnum
import rust.nostr.sdk.SignerAuthenticator
import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.initLogger
import kotlin.time.Duration
object NostrManager {
val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://user.kindpag.es",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
class Nostr {
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
private set
val messages = MessageManager(this)
val profiles = ProfileManager(this)
val relays = RelayManager(this)
private val isInitialized = MutableStateFlow(false)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
suspend fun emitNewEvent(event: UnsignedEvent) {
_newEvents.emit(event)
}
suspend fun init(
dbPath: String,
logLevel: LogLevel = LogLevel.WARN
) {
try {
if (isInitialized.value) return
// Initialize the logger for nostr client
initLogger(logLevel)
// Initialize configurations for nostr client
val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory()
// Initialize the authenticator
val authenticator = SignerAuthenticator(signer)
val idleTimeout = Duration.parse("5m")
client =
ClientBuilder()
.authenticator(authenticator)
.database(lmdb)
.gossip(gossip)
.gossipConfig(
GossipConfig()
.noBackgroundRefresh()
.fetchTimeout(Duration.parse("2s"))
)
.verifySubscriptions(false)
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
.build()
isInitialized.value = true
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
}
}
suspend fun waitUntilInitialized() {
isInitialized.first { it }
}
suspend fun connectBootstrapRelays() {
relays.connectBootstrapRelays()
}
suspend fun reconnect() {
relays.reconnect()
}
suspend fun disconnect() {
relays.disconnect()
}
suspend fun prune() {
try {
client?.database()?.wipe()
} catch (e: Exception) {
throw IllegalStateException("Failed to prune database: ${e.message}", e)
}
}
suspend fun setSigner(new: AsyncNostrSigner) {
try {
signer.switch(new)
} catch (e: Exception) {
throw IllegalStateException("Failed to set signer: ${e.message}", e)
}
}
fun isSignedByUser(event: Event): Boolean {
return try {
signer.publicKeyFlow.value == event.author()
} catch (e: Exception) {
println("Failed to check if event is signed by user: ${e.message}")
false
}
}
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
) = supervisorScope {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
var processedCount = 0
var eoseReceived = false
launch(Dispatchers.Default) {
for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event)
processedCount++
// Trigger new message notification
if (rumor != null) {
if (rumor.createdAt().asSecs() >= now.asSecs()) {
onNewMessage(rumor)
}
}
// Update sync state
messages.updateSyncState {
it.copy(
processedCount = processedCount,
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
)
}
}
}
while (true) {
val notification = notifications.next() ?: continue
when (notification) {
is ClientNotification.Message -> {
val relayUrl = notification.relayUrl
when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> {
val event = message.event
// Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id())
when (event.kind().asStd()) {
KindStandard.METADATA -> {
try {
val metadata = Metadata.fromJson(event.content())
onMetadataUpdate(event.author(), metadata)
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
}
KindStandard.CONTACT_LIST -> {
if (isSignedByUser(event = event)) {
val pubkeys = event.tags().publicKeys()
// Get mutual contacts
profiles.syncMutualContacts(pubkeys)
// Emit contact list update
onContactListUpdate(pubkeys)
}
}
KindStandard.INBOX_RELAYS -> {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
messages.getUserMessages(msgRelayList = event)
}
}
KindStandard.GIFT_WRAP -> {
giftWrapQueue.send(event)
}
else -> {}
}
}
is RelayMessageEnum.EndOfStoredEvents -> {
if (message.subscriptionId == "gift-wraps") {
eoseReceived = true
if (giftWrapQueue.isEmpty) {
messages.updateSyncState { it.copy(isSyncing = false) }
}
}
}
is RelayMessageEnum.Ok -> {
if (messages.sentEvents.containsKey(message.eventId)) {
val currentRelays =
messages.sentEvents[message.eventId] ?: emptyList()
messages.sentEvents[message.eventId] = currentRelays + relayUrl
}
}
else -> {
/* Ignore other message types */
}
}
}
is ClientNotification.Shutdown -> {
break
}
else -> {
/* Ignore other message types */
}
}
}
}
}

View File

@@ -0,0 +1,352 @@
package su.reya.coop.nostr
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Client
import rust.nostr.sdk.Contact
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.MetadataRecord
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.Nip05Profile
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Timestamp
import kotlin.time.Duration
class ProfileManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer
private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow()
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
_metadataUpdates.emit(pubkey to metadata)
}
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
_contactListUpdates.emit(contacts)
}
suspend fun getUserMetadata() {
try {
val author =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
// Get the latest metadata event
val metadataFilter =
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
// Get the latest contact list event
val contactFilter =
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
// Get the latest messaging relay list event
val msgRelayFilter =
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
// Construct a target that includes all filters
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
}
}
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
try {
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
}
}
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event
val relayList = nostr.relays.getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
client?.sendEvent(
event = relayListEvent,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.all(),
okTimeout = Duration.parse("3s")
)
// Send messaging relay list event
val msgRelayList = nostr.relays.getDefaultMsgRelayList()
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
client?.sendEvent(
event = msgRelayListEvent,
target = SendEventTarget.toNip65(),
ackPolicy = AckPolicy.none()
)
// Send metadata event
val metadata =
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
client?.sendEvent(
event = metadataEvent,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
// Send contact list event
val defaultContact =
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
client?.sendEvent(
event = contactListEvent,
target = SendEventTarget.toNip65(),
ackPolicy = AckPolicy.none()
)
nostr.setSigner(keys)
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: String? = null
): Metadata {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
val newRecord = record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
return newMetadata
} catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e)
}
}
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try {
val kind = Kind.fromStd(KindStandard.METADATA)
val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content())
} catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}")
null
}
}
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
try {
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
val events = client?.database()?.query(filter)
val results = mutableMapOf<PublicKey, Metadata>()
events?.toVec()?.forEach { event ->
try {
val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
}
return results
} catch (e: Exception) {
println("Failed to get all cache metadata: ${e.message}")
return emptyMap()
}
}
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
try {
val limit = keys.size.toULong() * 2u
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
// Construct a filter for metadata events
val filter = Filter()
.kind(Kind.fromStd(KindStandard.METADATA))
.authors(keys)
.limit(limit)
// Construct request target
val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
}
}
suspend fun setContactList(contacts: List<PublicKey>) {
try {
val contactList = contacts.map { Contact(it) }
val event = EventBuilder.contactList(contactList).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
}
}
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
try {
val response: HttpResponse = client.get(address.url())
val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
}
}
suspend fun searchByAddress(query: String): PublicKey {
try {
val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address)
return profile.publicKey()
} catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e)
}
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
// Add search relay
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
if (client?.relay(searchRelay) == null) {
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
client?.connectRelay(searchRelay)
}
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u)
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
val stream = client?.streamEvents(
target = target,
id = "search",
timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose
)
// Collect the results
val results = mutableListOf<PublicKey>()
// Keep searching until the stream is closed or timeout
stream?.next()?.let { event ->
val event = event.event ?: return@let
results.add(event.author())
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
}
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
try {
val filter = Filter().author(pubkey).limit(3u)
val target = mutableMapOf<RelayUrl, List<Filter>>()
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
val events = client?.fetchEvents(
target = ReqTarget.manual(target),
timeout = Duration.parse("3s")
)
return events?.first()?.createdAt()
} catch (e: Exception) {
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).author(currentUser).limit(1u)
val events = client?.database()?.query(filter)
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
return pubkeys.contains(pubkey)
} catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
val events = client?.database()?.query(filter)
val contacts = mutableSetOf<PublicKey>()
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
contacts.add(event.author())
}
return contacts.toSet()
} catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
}
}
}

View File

@@ -0,0 +1,183 @@
package su.reya.coop.nostr
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Client
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayStatus
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.extractRelayList
import rust.nostr.sdk.nip17ExtractRelayList
import kotlin.time.Duration
class RelayManager(private val nostr: Nostr) {
private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer
suspend fun connectBootstrapRelays() {
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse(url))
}
NostrManager.INDEXER_RELAY.forEach { url ->
client?.addRelay(
url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip()
)
}
// Connect to all bootstrap relays
client?.connect()
}
suspend fun reconnect() {
NostrManager.ALL_RELAYS.forEach { url ->
try {
client?.relay(RelayUrl.parse(url)).let { relay ->
if (relay != null) {
if (relay.status() != RelayStatus.CONNECTED) {
relay.connect()
}
}
}
} catch (e: Exception) {
println("Failed to reconnect relay: ${e.message}")
}
}
}
suspend fun disconnect() {
NostrManager.ALL_RELAYS.forEach { url ->
try {
client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) {
println("Failed to disconnect relay: ${e.message}")
}
}
}
internal suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
// Construct a list of relays
val relayList = mapOf(
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
)
// Ensure all relays are added and connected
relayList.forEach { (relay, metadata) ->
client?.addRelay(
url = relay,
capabilities =
when (metadata) {
RelayMetadata.READ -> RelayCapabilities.read()
RelayMetadata.WRITE -> RelayCapabilities.write()
}
)
client?.connectRelay(relay)
}
return relayList
}
internal suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
// Construct a list of messaging relays
val msgRelayList = listOf(
RelayUrl.parse("wss://auth.nostr1.com"),
RelayUrl.parse("wss://nip17.com"),
)
// Ensure all relays are added and connected
msgRelayList.forEach { relay ->
client?.addRelay(relay, RelayCapabilities.none())
client?.connectRelay(relay)
}
return msgRelayList
}
suspend fun setMsgRelays(urls: List<RelayUrl>) {
try {
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.toNip65(),
ackPolicy = AckPolicy.none(),
)
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(currentUser).limit(1u)
val target = ReqTarget.auto(listOf(filter))
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
client?.subscribe(target = target, closeOn = opts)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
try {
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
val event = events?.first() ?: return emptyList()
return nip17ExtractRelayList(event)
} catch (e: Exception) {
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
}
}
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
try {
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val target = ReqTarget.auto(listOf(filter))
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
}
}
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
}

View File

@@ -1,32 +1,44 @@
package su.reya.coop
package su.reya.coop.nostr
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.seconds
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
private val mutex = Mutex()
@Volatile
private var signer: AsyncNostrSigner = initialSigner
var currentUser: PublicKey? = null
private set
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
val publicKeyFlow = _publicKeyFlow.asStateFlow()
/**
* Get the current signer.
*/
suspend fun get(): AsyncNostrSigner = mutex.withLock {
signer
}
fun get(): AsyncNostrSigner = signer
/**
* Switch to a new signer.
*/
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
currentUser = newSigner.getPublicKeyAsync()
_publicKeyFlow.value = pubkey
}
override suspend fun getPublicKeyAsync(): PublicKey? {
@@ -52,4 +64,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
return get().nip44DecryptAsync(publicKey, payload)
}
}
}

View File

@@ -0,0 +1,13 @@
package su.reya.coop.repository
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.receiveAsFlow
object ErrorRepository {
private val _errors = Channel<String>(Channel.BUFFERED)
val errors = _errors.receiveAsFlow()
fun showError(message: String) {
_errors.trySend(message)
}
}

View File

@@ -0,0 +1,39 @@
package su.reya.coop.repository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
class MediaRepository {
private val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
suspend fun blossomUpload(
signer: AsyncNostrSigner,
file: ByteArray,
contentType: String? = "image/jpeg"
): String? {
return try {
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = signer,
)
descriptor?.url
} catch (e: Exception) {
println("Upload failed: ${e.message}")
null
}
}
}

View File

@@ -0,0 +1,225 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EncryptedSecretKey
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.nostr.ExternalSignerHandler
import su.reya.coop.nostr.ExternalSignerProxy
import su.reya.coop.nostr.Nostr
import su.reya.coop.nostr.SignerPermissions
import su.reya.coop.repository.MediaRepository
import su.reya.coop.storage.SecretStorage
import kotlin.time.Duration.Companion.seconds
data class AuthState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
)
class AuthViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : BaseViewModel() {
private val mediaRepository = MediaRepository()
companion object {
private const val KEY_USER_SIGNER = "user_signer"
private const val KEY_APP_KEYS = "app_keys"
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
}
private val _state = MutableStateFlow(AuthState())
val state = _state.asStateFlow()
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
viewModelScope.launch {
try {
val secret = withTimeoutOrNull(3.seconds) {
secretStore.get(KEY_USER_SIGNER)
}
if (secret == null) {
_state.update { it.copy(signerRequired = true) }
return@launch
}
runCatching {
val (signer, _) = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_state.update { it.copy(signerRequired = false) }
}.onFailure { e ->
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_state.update { it.copy(signerRequired = true) }
}
}
}
fun logout(onLogout: () -> Unit = {}) {
viewModelScope.launch {
try {
// Reset the nostr signer and prune the database
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
// Clear credentials from persistent storage
secretStore.clear(KEY_USER_SIGNER)
secretStore.clear(KEY_BANNER_DISMISSED)
// Call cleanup callback (e.g. to reset other ViewModels)
onLogout()
// Reset local states
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get(KEY_APP_KEYS)
// If app keys are already stored, use them
if (secret != null) return Keys.parse(secret)
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
return keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret) to null
secret.startsWith("ncryptsec1") -> {
if (password == null) throw IllegalArgumentException("Password is required")
val enc = EncryptedSecretKey.fromBech32(secret)
val secret = enc.decrypt(password)
val keys = Keys(secret)
keys to keys.secretKey().toBech32()
}
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds
NostrConnect(uri = bunker, appKeys, timeout, null) to null
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey) to null
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
suspend fun importIdentity(secret: String, password: String? = null) {
val (signer, decryptedSecret) = createSigner(secret, password)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, decryptedSecret ?: secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set(KEY_USER_SIGNER, "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states
_state.update { it.copy(signerRequired = false) }
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
// Create identity
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set(KEY_USER_SIGNER, secret)
// Update local states
_state.update { it.copy(signerRequired = false) }
}
}

View File

@@ -0,0 +1,10 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import su.reya.coop.repository.ErrorRepository
abstract class BaseViewModel : ViewModel() {
protected fun showError(message: String) {
ErrorRepository.showError(message)
}
}

View File

@@ -0,0 +1,260 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import su.reya.coop.roomId
data class ChatState(
val rooms: Set<Room> = emptySet(),
val isSyncing: Boolean = false,
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val _state = MutableStateFlow(ChatState())
val state = combine(
_state,
nostr.messages.messageSyncState
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
ChatState()
)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
val chatRooms = state.map { it.rooms }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
val isSyncing = state.map { it.isSyncing }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
init {
viewModelScope.launch {
nostr.waitUntilInitialized()
// Observe message sync progress
launch {
nostr.messages.messageSyncState.collect { syncState ->
// When at least some messages are processed, allow UI to show the list
if (syncState.processedCount > 0) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 10 messages OR when sync is fully done
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
val newRoom = Room.new(event, currentUser)
_state.update {
it.copy(
rooms = (it.rooms + newRoom).sortedDescending().toSet()
)
}
} else {
updateRoomList(roomId, event)
}
_newEvents.emit(event)
}
}
// Initial load of rooms
refreshChatRooms()
}
}
fun createChatRoom(to: List<PublicKey>): Long {
try {
if (to.isEmpty()) {
throw IllegalArgumentException("At least one recipient is required")
}
// Get current user
val currentUser = nostr.signer.publicKeyFlow.value
?: throw IllegalStateException("User not signed in")
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.finalizeUnsigned(currentUser)
// Check if the room already exists
val id = rumor.roomId()
val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
// If the room already exists, return its ID
if (existingRoom != null) {
return existingRoom.id
}
// Create a room from the rumor event
val room = Room.new(rumor, currentUser)
// Update the chat rooms state
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms.firstOrNull { it.id == id }
}
suspend fun refreshChatRooms() {
try {
val rooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
// Add or update rooms from the database
rooms.forEach { room ->
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency
currentState.copy(rooms = merged.values.sortedDescending().toSet())
}
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.messages.getChatRoomMessages(roomId)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
fun chatRoomConnect(roomId: Long) {
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
nostr.messages.chatRoomConnect(members.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
if (message.isEmpty()) {
showError("Message cannot be empty")
}
viewModelScope.launch {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
to = room.members,
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = { event ->
updateRoomList(roomId, event)
viewModelScope.launch { _newEvents.emit(event) }
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
throw IllegalArgumentException("Error: ${e.message}")
}
}
fun isMessageSent(id: EventId): Boolean {
val giftWrapId = nostr.messages.rumorMap[id]
if (giftWrapId != null) {
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
return isSent
} else {
return false
}
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_state.update { currentState ->
val updatedRooms = currentState.rooms.map { room ->
if (room.id == roomId) {
room.copy(
lastMessage = newMessage.content(),
createdAt = newMessage.createdAt()
)
} else {
room
}
}.sortedDescending().toSet()
currentState.copy(rooms = updatedRooms)
}
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptySet(),
isPartialProcessedGiftWrap = false,
)
}
}
}

View File

@@ -0,0 +1,446 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import su.reya.coop.repository.MediaRepository
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class NostrAppState(
val isBusy: Boolean = false,
val isRelayListEmpty: Boolean = false,
)
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
private val mediaRepository = MediaRepository()
private val alwaysRunTasks = flow {
coroutineScope {
val observerJob = launch { runObserver() }
val batchingJob = launch { runMetadataBatching() }
try {
emit(Unit)
awaitCancellation()
} finally {
observerJob.cancel()
batchingJob.cancel()
}
}
}
private val _appState = MutableStateFlow(NostrAppState())
val appState: StateFlow<NostrAppState> =
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = NostrAppState()
)
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val profilesMutex = Mutex()
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
val isBusy = appState.map { it.isBusy }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile = nostr.signer.publicKeyFlow
.flatMapLatest { pubkey ->
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
init {
// Automatically reconnect bootstrap relays
reconnect()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays()
// Get all local stored metadata
getCacheMetadata()
}
private fun reconnect() {
viewModelScope.launch {
nostr.waitUntilInitialized()
nostr.reconnect()
}
}
private suspend fun runObserver() = coroutineScope {
// Observe contact list updates
launch {
nostr.profiles.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
// Observe metadata updates
launch {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
// Get the first pubkey
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
// Get current time
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
// Get the next pubkey
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
// Only add the pubkey if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.profiles.fetchMetadataBatch(keysToRequest)
}
}
}
}
private fun getCacheMetadata() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
// Update the metadata state
updateMetadata(pubkey, Profile(pubkey, metadata))
// Update seenPublicKeys to avoid duplicate requests
seenPublicKeys.add(pubkey)
}
}
}
private fun observeSignerAndCheckRelays() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Wait until a signer is explicitly set (which updates publicKeyFlow)
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
// Get all metadata for the current user
nostr.profiles.getUserMetadata()
// Small delay to ensure all relays are connected
delay(2.seconds)
// Check if the relay list is empty
val relays = nostr.relays.getMsgRelays(currentUser)
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
viewModelScope.launch {
profilesMutex.withLock {
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
}
}
}
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) requestMetadata(pubkey)
return flow.asStateFlow()
}
fun resetInternalState() {
_contactList.value = emptySet()
_appState.update {
it.copy(
isRelayListEmpty = false,
)
}
}
fun dismissRelayWarning() {
_appState.update { it.copy(isRelayListEmpty = false) }
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_appState.update { it.copy(isBusy = true) }
try {
val avatarUrl =
picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
// Update the metadata state after successfully published
updateMetadata(currentUser, Profile(currentUser, newMetadata))
// Update local state
_appState.update { it.copy(isBusy = false) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun refetchMsgRelays() {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getRelayList(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyMap()
}
}
suspend fun addInboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addOutboxRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayList().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelaylist(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
try {
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
return nostr.relays.getMsgRelays(currentUser)
} catch (e: Exception) {
showError("Error: ${e.message}")
return emptyList()
}
}
suspend fun addMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun removeMsgRelay(relay: String) {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
private suspend fun newContact(publicKey: PublicKey) {
if (publicKey in contactList.value) return
try {
val updated = contactList.value + publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it + publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun addContact(address: String): Boolean {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return false
}
return run {
newContact(pubkey)
true
}
}
fun removeContact(publicKey: PublicKey) {
viewModelScope.launch {
if (publicKey !in contactList.value) return@launch
try {
val updated = contactList.value - publicKey
// Publish new event
nostr.profiles.setContactList(updated.toList())
// Optimistic local update
_contactList.update { it - publicKey }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.profiles.searchByAddress(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.profiles.searchByNostr(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
return try {
nostr.profiles.verifyActivity(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
null
}
}
suspend fun verifyContact(pubkey: PublicKey): Boolean {
return try {
nostr.profiles.verifyContact(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
false
}
}
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
return try {
nostr.profiles.mutualContacts(pubkey)
} catch (e: Exception) {
showError("Error: ${e.message}")
setOf()
}
}
}

70
zapstore.yaml Normal file
View File

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