Compare commits

..
Author SHA1 Message Date
reya 9230628441 update concord backend 2026-09-15 08:26:07 +07:00
reya 871efa6b6f add concord backend 2026-09-15 07:56:09 +07:00
reya 8b0cbd294e add concord plan 2026-09-15 07:35:49 +07:00
reya e221eda546 add concord crypto 2026-09-15 07:19:58 +07:00
reya e49168851e add plan 2026-09-14 20:33:27 +07:00
reya b6f4a97778 feat: add support for reaction (#52)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/52
2026-09-07 13:30:42 +00:00
reya 7009dbf692 chore: update bootstrap relay set 2026-08-03 15:24:52 +07:00
reya 9a2bd3816f chore: improve message reply (#50)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/50
2026-07-22 00:55:06 +00:00
reya 44c743d039 chore: bump version 2026-07-21 10:45:21 +07:00
reya a124dc4cc4 feat: add support for unread message and room highlighting (#49)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/49
2026-07-21 03:44:53 +00:00
reya 4a8134dec0 chore: improve notification (#48)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/48
2026-07-20 07:02:00 +00:00
reya 07085449a4 feat: add settings screen (#47)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/47
2026-07-20 03:30:26 +00:00
reya 9defad522c feat: add some basic interactive animations (#46)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/46
2026-07-17 09:27:39 +00:00
reya 47196dbea4 chore: bump version 2026-07-15 19:44:52 +07:00
reya 30191170f1 chore: fix some crash and performance issues (#45)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/45
2026-07-15 12:44:28 +00:00
reya 1485a767ed feat: add support for reply message (#44)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/44
2026-07-14 10:22:27 +00:00
reya 0e789b5d91 feat: add context menu for chat message (#43)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/43
2026-07-13 10:56:23 +00:00
reya 410615bd89 chore: clean up codebase (#42)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/42
2026-07-13 01:04:15 +00:00
reya a5951e03cf chore: improve performance (#41)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/41
2026-07-11 12:45:53 +00:00
reya 175c06a1a8 chore: refactor the internal storage layer (#40)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/40
2026-07-09 07:53:22 +00:00
reya c7a646ae73 chore: improve line break handling (#39)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/39
2026-07-09 03:48:42 +00:00
reya 7a44e836a9 chore: bump version 2026-07-07 13:29:11 +07:00
reya 4dae4045ae Merge pull request 'chore: improve loading state handling' (#38) from fix-loading-state into master
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/38
2026-07-07 04:09:28 +00:00
reya 9df4789b62 update 2026-07-07 11:09:06 +07:00
reya ea7e3fd3ed . 2026-07-07 10:59:09 +07:00
reya a76c8d117b fix syncing state 2026-07-07 08:27:25 +07:00
reya 9ed29c90ba chore: improve authentication handling (#37)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/37
2026-07-06 07:33:06 +00:00
reya 80c6426d27 chore: bump version 2026-07-04 09:30:30 +07:00
reya 801347ccb1 feat: render image in chat message (#36)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/36
2026-07-04 02:25:09 +00:00
reya 19daea119d chore: confirm before delete contact (#35)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/35
2026-07-03 09:09:56 +00:00
reya 8b2f0faa59 feat: add support for simple speech to text (#34)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/34
2026-07-03 08:38:58 +00:00
reya a2d28ecc40 chore: refactor the internal structure (#33)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/33
2026-07-03 01:20:36 +00:00
reya cd5a393a01 feat: add basic support for file upload via blossom (#32)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/32
2026-06-30 08:11:19 +00:00
reya 3bfe2308ba chore: bump version 2026-06-30 07:28:21 +07:00
reya eed9d401da chore: re-structure project (#31)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/31
2026-06-29 13:42:56 +00:00
reya 3943e2baab chore: remove force unwraps (#30)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/30
2026-06-28 02:06:57 +00:00
reya d5ea5570b6 chore: improve event syncing (#29)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/29
2026-06-28 01:28:25 +00:00
reya 26f9e577f4 chore: bump version 2026-06-27 10:26:55 +07:00
reya dd416711e2 fix: app crash when message id is null (#28)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/28
2026-06-27 03:26:07 +00:00
reya b90279e174 chore: improve qr scan result handling (#27)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/27
2026-06-27 01:14:26 +00:00
reya 25ba03562a chore: improve handling of loading states (#26)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/26
2026-06-26 09:23:03 +00:00
reya 010a8b4060 feat: wipe database on logout (#25)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/25
2026-06-25 08:12:27 +00:00
reya 750d46bc9e chore: update zapstore.yaml 2026-06-25 10:14:32 +07:00
reya 790d24c24b chore: update metadata 2026-06-25 09:53:38 +07:00
reya 4fbc8446d3 fix: release source endpoint 2026-06-25 09:26:15 +07:00
reya b4518ce360 chore: add app screenshots 2026-06-25 09:19:16 +07:00
reya 0f4bb71a39 chore: prepare to publish 2026-06-24 17:36:04 +07:00
reya 37003e2fc4 chore: bump version 2026-06-20 13:28:39 +07:00
reya 154906017e feat: screener for message requests (#24)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/24
2026-06-19 09:06:49 +00:00
reya 91e4e3b43d feat: support grouped new chat requests (#23)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/23
2026-06-18 00:34:23 +00:00
reya ea90a43909 feat: add contact list screen (#22)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/22
2026-06-15 07:38:53 +00:00
reya 938b192136 fix: foreground service crashing (#21)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/21
2026-06-13 02:12:16 +00:00
reya 1f966de654 chore: bump version 2026-06-12 15:50:14 +07:00
reya 00821a864b fix: nostr operations cause app crashing (#20)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/20
2026-06-12 08:49:14 +00:00
reya 28550f8e25 feat: Relay Management (#19)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/19
2026-06-11 10:40:36 +00:00
reya a759ad48e4 chore: bump version 2026-06-09 14:55:47 +07:00
reya 0d6b92b0c7 feat: Add support for NIP-55 (#18)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/18
2026-06-09 07:53:48 +00:00
reya 6a69d3a5b2 chore: update nostr sdk 2026-06-08 16:40:56 +07:00
reya 50b7f7a3f3 chore: bump version 2026-06-07 07:37:30 +07:00
reya a65aa70a55 chore: optimize the battery usage (#16)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/16
2026-06-07 00:35:46 +00:00
reya 74a37320fe fix: occasional splash screen hang (#15)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/15
2026-06-06 07:30:47 +00:00
reya b8b3b83952 feat: add update profile screen (#14)
Reviewed-on: https://git.reya.su/reya/coop-mobile/pulls/14
2026-06-06 05:50:32 +00:00
92 changed files with 10528 additions and 2777 deletions
+1
View File
@@ -17,3 +17,4 @@ captures
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
node_modules/
.artifacts/
+93
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
+1129
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+4 -4
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.6"
versionName = "0.2.6"
}
packaging {
resources {
+14 -3
View File
@@ -7,10 +7,20 @@
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<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"
@@ -28,7 +38,8 @@
<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" />
@@ -51,7 +62,7 @@
android:name=".NostrForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:foregroundServiceType="remoteMessaging" />
</application>
</manifest>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M440,680L520,680L520,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360Q497,360 508.5,348.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>
@@ -0,0 +1,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="M480,840L480,760L760,760Q760,760 760,760Q760,760 760,760L760,200Q760,200 760,200Q760,200 760,200L480,200L480,120L760,120Q793,120 816.5,143.5Q840,167 840,200L840,760Q840,793 816.5,816.5Q793,840 760,840L480,840ZM400,680L345,622L447,520L120,520L120,440L447,440L345,338L400,280L600,480L400,680Z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
</vector>
@@ -0,0 +1,49 @@
package su.reya.coop
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AndroidConnectivityMonitor(context: Context) : ConnectivityMonitor {
private val manager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _isMobileData = MutableStateFlow(checkIsMobileData())
override val isMobileData: StateFlow<Boolean> = _isMobileData.asStateFlow()
init {
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
manager.registerNetworkCallback(
networkRequest,
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isMobileData.value = checkIsMobileData()
}
override fun onLost(network: Network) {
_isMobileData.value = checkIsMobileData()
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
_isMobileData.value = checkIsMobileData()
}
})
}
private fun checkIsMobileData(): Boolean {
val activeNetwork = manager.activeNetwork ?: return false
val capabilities = manager.getNetworkCapabilities(activeNetwork) ?: return false
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
}
}
@@ -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)
}
}
@@ -2,50 +2,33 @@ 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.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
@@ -54,7 +37,10 @@ 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.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.SettingsRepository
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,10 +49,27 @@ 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.SettingsScreen
import su.reya.coop.screens.UpdateProfileScreen
import su.reya.coop.screens.chat.ChatScreen
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
import su.reya.coop.viewmodel.ChatViewModel
import su.reya.coop.viewmodel.ProfileCache
import su.reya.coop.viewmodel.SettingsViewModel
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
val LocalProfileCache = staticCompositionLocalOf<ProfileCache> {
error("No ProfileCache provided")
}
val LocalSettings = staticCompositionLocalOf<Settings> {
error("No Settings provided")
}
val LocalConnectivity = staticCompositionLocalOf<Boolean> {
false
}
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
@@ -83,31 +86,72 @@ val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun App(viewModel: NostrViewModel) {
fun App(
profileCache: ProfileCache,
accountRepository: AccountRepository,
chatRepository: ChatRepository,
settingsRepository: SettingsRepository,
connectivityMonitor: ConnectivityMonitor,
) {
val viewModelFactory = remember {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val result = when {
modelClass.isAssignableFrom(ChatViewModel::class.java) -> ChatViewModel(
chatRepository
)
modelClass.isAssignableFrom(AccountViewModel::class.java) -> AccountViewModel(
accountRepository
)
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> SettingsViewModel(
settingsRepository
)
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
@Suppress("UNCHECKED_CAST")
return result as T
}
}
}
val accountViewModel: AccountViewModel = viewModel(factory = viewModelFactory)
val chatViewModel: ChatViewModel = viewModel(factory = viewModelFactory)
val settingsViewModel: SettingsViewModel = viewModel(factory = viewModelFactory)
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 accountState by accountViewModel.state.collectAsStateWithLifecycle()
val signerRequired = accountState.signerRequired
// Get the settings
val settings by settingsViewModel.settings.collectAsStateWithLifecycle()
// Get connectivity status
val isMobileData by connectivityMonitor.isMobileData.collectAsStateWithLifecycle()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Check if dark theme enabled
val darkMode = isSystemInDarkTheme()
val darkMode = when (settings.theme) {
Theme.Light -> false
Theme.Dark -> true
Theme.System -> isSystemInDarkTheme()
}
// 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 -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
settings.dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme()
@@ -115,13 +159,21 @@ fun App(viewModel: NostrViewModel) {
else -> expressiveLightColorScheme()
}
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) {
viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
launch {
accountViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
chatViewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
launch {
profileCache.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
}
@@ -130,7 +182,7 @@ fun App(viewModel: NostrViewModel) {
fun handleIntent(intent: Intent) {
val screen = Screen.fromIntent(intent)
// Prevent pushing the same screen
if (screen != null && backStack.lastOrNull() != screen) {
if ((screen != null) && (backStack.lastOrNull() != screen)) {
navigator.navigate(screen)
}
}
@@ -163,7 +215,9 @@ fun App(viewModel: NostrViewModel) {
motionScheme = MotionScheme.expressive(),
) {
CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalProfileCache provides profileCache,
LocalSettings provides settings,
LocalConnectivity provides isMobileData,
LocalSnackbarHostState provides snackbarHostState,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
@@ -183,92 +237,68 @@ fun App(viewModel: NostrViewModel) {
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen()
HomeScreen(accountViewModel, chatViewModel)
}
entry<Screen.RequestList> {
RequestListScreen(chatViewModel)
}
entry<Screen.Onboarding> {
OnboardingScreen()
OnboardingScreen(accountViewModel)
}
entry<Screen.Import> {
ImportScreen()
ImportScreen(accountViewModel)
}
entry<Screen.NewIdentity> {
NewIdentityScreen()
NewIdentityScreen(accountViewModel)
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id)
val factory = remember(key) {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return ChatScreenViewModel(
key.id,
key.screening,
accountRepository,
chatRepository
) as T
}
}
}
ChatScreen(
viewModel<ChatScreenViewModel>(
key = key.id.toString(),
factory = factory
),
accountViewModel
)
}
entry<Screen.NewChat> {
NewChatScreen()
NewChatScreen(accountViewModel, chatViewModel)
}
entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey)
ProfileScreen(pubkey = key.pubkey, chatViewModel = chatViewModel)
}
entry<Screen.UpdateProfile> {
UpdateProfileScreen(accountViewModel)
}
entry<Screen.Scan> {
ScanScreen()
}
entry<Screen.MyQr> {
MyQrScreen()
MyQrScreen(accountViewModel)
}
entry<Screen.ContactList> {
ContactListScreen(accountViewModel, chatViewModel)
}
entry<Screen.Relay> {
RelayScreen()
RelayScreen(accountViewModel)
}
entry<Screen.Settings> {
SettingsScreen(settingsViewModel)
}
}
)
// 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,
)
}
}
}
}
}
}
}
@@ -1,8 +1,13 @@
package su.reya.coop.coop.storage
package su.reya.coop
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import javax.crypto.Cipher
@@ -10,10 +15,9 @@ import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
data class SecretEntry(
val encrypted: String,
val iv: String
)
private val Context.dataStore by preferencesDataStore("secret_store")
data class SecretEntry(val encrypted: String, val iv: String)
class SecretCrypto {
private val keyAlias = "coop"
@@ -21,11 +25,9 @@ class SecretCrypto {
private val transformation = "AES/GCM/NoPadding"
fun encrypt(content: String): SecretEntry {
// Initialize cipher
val cipher = Cipher.getInstance(transformation)
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
// Encrypt content
val encrypted = cipher.doFinal(content.toByteArray())
val iv = cipher.iv
@@ -39,12 +41,10 @@ class SecretCrypto {
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
// Initialize cipher
val cipher = Cipher.getInstance(transformation)
val spec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
// Decrypt content
val plaintext = cipher.doFinal(encrypted)
return String(plaintext, StandardCharsets.UTF_8)
@@ -54,13 +54,9 @@ class SecretCrypto {
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
val existingKey = keyStore.getKey(keyAlias, null)
// Return existing key if available
if (existingKey is SecretKey) return existingKey
// Construct a new key generator
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
// Initialize key generation parameters
val spec = KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
@@ -70,9 +66,49 @@ class SecretCrypto {
.setKeySize(256)
.build()
// Generate a new key
keyGenerator.init(spec)
return keyGenerator.generateKey()
}
}
}
class AppStore(private val context: Context) : AppStorage {
private val crypto = SecretCrypto()
override suspend fun get(key: String): String? {
return context.dataStore.data.first()[stringPreferencesKey(key)]
}
override suspend fun set(key: String, value: String) {
context.dataStore.edit { it[stringPreferencesKey(key)] = value }
}
override suspend fun getSecret(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun setSecret(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey(key))
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs.contains(stringPreferencesKey(key)) ||
prefs.contains(stringPreferencesKey("${key}_encrypted"))
}
}
@@ -0,0 +1,38 @@
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.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class ExternalSignerLauncher(
private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main,
) {
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(mainDispatcher) {
val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
deferred.await()
}
}
fun onResult(result: ActivityResult) {
pendingResult?.complete(result)
pendingResult = null
}
}
@@ -1,35 +1,63 @@
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.viewModels
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore
import kotlinx.coroutines.MainScope
import su.reya.coop.nostr.NostrManager
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.MediaRepository
import su.reya.coop.repository.SettingsRepository
import su.reya.coop.viewmodel.ProfileCache
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
private val viewModel: NostrViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity)
return NostrViewModel(NostrManager.instance, secretStore) as T
}
}
companion object {
val externalSignerLauncher = ExternalSignerLauncher()
}
private val profileCache by lazy { ProfileCache(NostrManager.instance) }
private val scope = MainScope()
private val connectivityMonitor by lazy { AndroidConnectivityMonitor(this@MainActivity) }
private val settingsRepository by lazy {
val storage = AppStore(this@MainActivity)
SettingsRepository(storage, scope)
}
private val accountRepository by lazy {
val storage = AppStore(this@MainActivity)
val mediaRepository = MediaRepository(settingsRepository)
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
AccountRepository(
NostrManager.instance,
storage,
mediaRepository,
settingsRepository,
scope,
androidSigner
)
}
private val chatRepository by lazy {
val mediaRepository = MediaRepository(settingsRepository)
ChatRepository(NostrManager.instance, mediaRepository, settingsRepository, scope)
}
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace()
android.util.Log.e(
"CoopCrash",
"Uncaught exception in thread ${thread.name}",
throwable
Log.e(
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
)
// Start the Crash Activity
@@ -40,33 +68,42 @@ class MainActivity : ComponentActivity() {
startActivity(intent)
// Exit
android.os.Process.killProcess(android.os.Process.myPid())
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
accountRepository.state.value.signerRequired == null
}
setContent {
App(viewModel = viewModel)
App(
profileCache = profileCache,
accountRepository = accountRepository,
chatRepository = chatRepository,
settingsRepository = settingsRepository,
connectivityMonitor = connectivityMonitor,
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -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
@@ -49,4 +58,7 @@ sealed interface Screen : NavKey {
@Serializable
data object Relay : Screen
@Serializable
data object Settings : Screen
}
@@ -10,43 +10,50 @@ import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
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 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()
val notification = createNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(1, notification)
}
startAsForeground()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
serviceScope.launch {
if (intent?.action == "STOP_SERVICE") {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
// Start the nostr service in the foreground
startAsForeground()
// Check if the service is already running
if (notificationJob?.isActive == true) return START_STICKY
// Start the Nostr client
notificationJob = serviceScope.launch {
try {
Log.d("Coop", "Starting Nostr in background")
@@ -55,19 +62,22 @@ class NostrForegroundService : Service() {
dbDir.mkdirs()
// Initialize Nostr client
nostr.init(dbDir.absolutePath)
try {
nostr.init(dbDir.absolutePath, AppStore(this@NostrForegroundService))
} 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 {
@@ -82,10 +92,30 @@ class NostrForegroundService : Service() {
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)
@@ -108,10 +138,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")
@@ -157,4 +189,13 @@ class NostrForegroundService : Service() {
super.onDestroy()
serviceScope.cancel()
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (!NotificationManagerCompat.from(this).areNotificationsEnabled()) {
Log.d("Coop", "Stopping service on task removed because notifications are disabled")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
@@ -1,387 +0,0 @@
package su.reya.coop.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import 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,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(id, text)
text = ""
}
)
}
}
}
)
}
@Composable
fun DateSeparator(date: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = date,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
@Composable
fun ChatMessage(
rumor: UnsignedEvent
) {
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser()
val isMine = rumor.author() == currentUser
val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
} else {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
}
val containerColor =
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
val contentColor =
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
) {
Surface(
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
modifier = Modifier
.widthIn(max = 280.dp)
.clickable(
onClick = {
val id = rumor.id()
if (id != null) {
val sent = viewModel.isMessageSent(id)
println("Sent: $sent")
}
}
)
) {
Text(
text = rumor.content(),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
fun ChatInput(
value: String,
onValueChange: (String) -> Unit,
onSend: () -> Unit
) {
Surface(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Bottom
) {
TextField(
value = value,
onValueChange = onValueChange,
placeholder = { Text("Message") },
shape = RoundedCornerShape(28.dp),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.size(8.dp))
FilledTonalIconButton(
onClick = onSend,
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f),
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = painterResource(Res.drawable.ic_send),
contentDescription = "Send"
)
}
}
}
}
@@ -0,0 +1,366 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.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.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 org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactListScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
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(
accountViewModel = accountViewModel,
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 { accountViewModel.removeContact(it) }
contactToDelete = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { contactToDelete = null }) {
Text("Cancel")
}
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AddContactDialog(
accountViewModel: AccountViewModel,
onDismissRequest: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current
val focusRequester = remember { FocusRequester() }
var contact by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
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 = {
accountViewModel.addContact(contact)
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 profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle(initialValue = 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
}
}
@@ -7,15 +7,17 @@ import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
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.imePadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -23,6 +25,8 @@ 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.ExperimentalMaterial3Api
@@ -59,7 +63,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
@@ -72,55 +75,70 @@ 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.flow.flowOf
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.RoomUiState
import su.reya.coop.Screen
import su.reya.coop.ago
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.displayNameFlow
import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen() {
fun HomeScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
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 isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val sheetState = rememberModalBottomSheetState(true)
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val userProfile by accountViewModel.currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val isRelayListEmpty = accountState.isRelayListEmpty
val isBannerDismissed = accountState.isNotificationBannerDismissed
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
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())
@@ -132,13 +150,18 @@ fun HomeScreen() {
// State will be updated by LifecycleResumeEffect
}
// Partition chat rooms into requests and ongoing
val (requests, ongoing) = remember(chatRooms) {
chatRooms.partition { it.kind == RoomKind.Request }
}
LifecycleResumeEffect(context) {
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
LaunchedEffect(Unit) {
viewModel.getChatRooms()
LaunchedEffect(accountState.signerRequired) {
chatViewModel.refreshChatRooms()
}
LaunchedEffect(qrScanResult.content) {
@@ -146,7 +169,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) }
@@ -167,10 +190,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
@@ -183,8 +214,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,
)
}
@@ -222,7 +253,9 @@ fun HomeScreen() {
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (!isNotificationEnabled && !isBannerDismissed) {
AnimatedVisibility(
visible = !isNotificationEnabled && !isBannerDismissed,
) {
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -254,7 +287,7 @@ fun HomeScreen() {
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { viewModel.dismissNotificationBanner() },
onClick = { accountViewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f),
) {
Text(text = "Maybe later")
@@ -293,11 +326,9 @@ fun HomeScreen() {
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
isRefreshing = true
chatViewModel.refreshChatRooms()
isRefreshing = false
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
@@ -342,104 +373,20 @@ fun HomeScreen() {
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
if (requests.isNotEmpty()) {
item { NewRequests(requests) }
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
modifier = Modifier
.imePadding()
.navigationBarsPadding(),
) {
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 ->
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?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Spacer(modifier = Modifier.size(8.dp))
items(ongoing, key = { it.id }) { room ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.animateItem()
) {
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"
)
}
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
@@ -447,42 +394,385 @@ fun HomeScreen() {
}
},
)
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,
accountViewModel = accountViewModel,
chatViewModel = chatViewModel
)
}
}
}
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
ModalBottomSheet(
onDismissRequest = { accountViewModel.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 = {
isBusy = true
accountViewModel.refetchMsgRelays()
isBusy = false
},
modifier = Modifier
.weight(1f)
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Retry",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
Button(
enabled = !isBusy,
onClick = {
accountViewModel.useDefaultMsgRelayList()
scope.launch { 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 profileCache = LocalProfileCache.current
val total = requests.size
val firstRoom = requests.getOrNull(0)
val secondRoom = requests.getOrNull(1)
val firstRoomState by remember(firstRoom?.id) {
firstRoom?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
}.collectAsStateWithLifecycle(RoomUiState())
val secondRoomState by remember(secondRoom?.id) {
(secondRoom ?: firstRoom)?.uiStateFlow(profileCache) ?: flowOf(RoomUiState())
}.collectAsStateWithLifecycle(RoomUiState())
val supportingText = when {
total == 1 && firstRoom != null -> {
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 -> ""
}
val totalUnread = requests.sumOf { it.unreadCount }
ListItem(
modifier = Modifier.clickable {
navigator.navigate(Screen.RequestList)
},
leadingContent = {
BadgedBox(
badge = {
if (totalUnread > 0) {
Badge {
Text(totalUnread.toString())
}
}
}
) {
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.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal
)
)
},
supportingContent = {
if (supportingText.isNotEmpty()) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (totalUnread > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (totalUnread > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
),
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 profileCache = LocalProfileCache.current
val roomState by remember(room.id) { room.uiStateFlow(profileCache) }
.collectAsStateWithLifecycle(RoomUiState())
ListItem(
modifier = Modifier.clickable(onClick = onClick),
leadingContent = {
Avatar(picture = picture, description = displayName)
BadgedBox(
badge = {
if (room.unreadCount > 0) {
Badge {
Text(room.unreadCount.toString())
}
}
}
) {
Avatar(picture = roomState.picture, description = roomState.picture)
}
},
headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = displayName,
style = MaterialTheme.typography.titleMediumEmphasized,
text = roomState.name,
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal
),
modifier = Modifier.weight(1f)
)
Text(
text = room.createdAt.ago(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.outline,
textAlign = TextAlign.End,
)
}
},
supportingContent = {
if (!room.lastMessage.isNullOrBlank()) {
Text(
text = room.lastMessage!!,
style = MaterialTheme.typography.bodyMedium
text = room.lastMessage ?: "",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (room.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
color = if (room.unreadCount > 0) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline
),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
},
@@ -495,16 +785,17 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit
onDismiss: (suspend () -> Unit) -> Unit,
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel,
) {
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.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 { }
"Settings" to { navigator.navigate(Screen.Settings) }
)
Column(
@@ -528,7 +819,14 @@ fun BottomMenuList(
}
Spacer(modifier = Modifier.size(16.dp))
FilledTonalButton(
onClick = { viewModel.logout() },
onClick = {
onDismiss {
accountViewModel.logout(onLogout = {
accountViewModel.resetInternalState()
chatViewModel.resetInternalState()
})
}
},
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
@@ -22,7 +22,6 @@ 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
@@ -30,86 +29,78 @@ 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.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.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation
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_scanner
import kotlinx.coroutines.flow.flowOf
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.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
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportScreen() {
fun ImportScreen(viewModel: AccountViewModel) {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
val accountState by viewModel.state.collectAsStateWithLifecycle()
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
var password by remember { mutableStateOf("") }
var requirePassword by remember { mutableStateOf(false) }
// Get metadata when pubkey changes
val metadata by remember(pubkey) {
pubkey?.let(viewModel::getMetadata) ?: flowOf(null)
}.collectAsStateWithLifecycle(null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching {
if (result.startsWith("nsec")) {
if (result.startsWith("nsec1") || result.startsWith("ncryptsec1")) {
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
}
}
// Navigate to Home on successful import (signerRequired becomes false)
LaunchedEffect(accountState.signerRequired) {
if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -149,35 +140,6 @@ fun ImportScreen() {
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Cookie9Sided.toShape(),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
}
Surface(
modifier = Modifier
.fillMaxWidth()
@@ -188,7 +150,7 @@ fun ImportScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.padding(24.dp),
) {
Column(
modifier = Modifier
@@ -205,9 +167,9 @@ fun ImportScreen() {
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !isLoggedIn,
enabled = !accountState.isImporting,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
@@ -239,32 +201,57 @@ 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 = !accountState.isImporting && 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 = {
scope.launch {
if (pubkey == null) {
viewModel.verifyIdentity(secret).let { pubkey = it }
} else {
// Import the identity
viewModel.importIdentity(secret)
// Navigate to the home screen
navigator.navigate(Screen.Home)
}
}
viewModel.importIdentity(secret, password)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoggedIn,
enabled = secret.isNotBlank() && !accountState.isImporting,
) {
if (isLoggedIn) {
if (accountState.isImporting) {
LoadingIndicator()
} else {
Text(
text = if (pubkey == null) "Verify" else "Click again to Continue",
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
@@ -13,22 +13,23 @@ 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
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.viewmodel.AccountViewModel
@Composable
fun MyQrScreen() {
fun MyQrScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -59,7 +60,7 @@ fun MyQrScreen() {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = rememberQrCodePainter(currentUser.toBech32()),
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
contentDescription = "My QR"
)
}
@@ -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
@@ -55,30 +56,36 @@ import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
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.short
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatViewModel
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewChatScreen() {
fun NewChatScreen(
accountViewModel: AccountViewModel,
chatViewModel: ChatViewModel
) {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val viewModel = LocalNostrViewModel.current
val accountState by accountViewModel.state.collectAsStateWithLifecycle()
val contactList = accountState.contactList
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,14 +98,16 @@ fun NewChatScreen() {
selectedReceivers.add(pubkey)
}
} else if (query.contains("@")) {
val pubkey = viewModel.searchByAddress(query)
if (pubkey != null) {
selectedReceivers.add(pubkey)
accountViewModel.searchByAddress(query) { pubkey ->
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
}
} else {
val results = viewModel.searchByNostr(query)
searchResults.clear()
searchResults.addAll(results)
accountViewModel.searchByNostr(query) { results ->
searchResults.clear()
searchResults.addAll(results)
}
}
query = ""
@@ -108,14 +117,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 +175,7 @@ fun NewChatScreen() {
) {
ExtendedFloatingActionButton(
onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
navigator.navigate(Screen.Chat(roomId))
},
expanded = false,
@@ -178,8 +186,8 @@ fun NewChatScreen() {
)
},
text = { Text("Next") },
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary,
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
@@ -258,7 +266,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 +277,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 +294,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 profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip(
@@ -300,7 +304,7 @@ fun ReceiverChip(
onClick = onRemove,
label = {
Text(
text = displayName,
text = profile?.name ?: "No name",
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
@@ -308,8 +312,8 @@ fun ReceiverChip(
},
avatar = {
Avatar(
picture = picture,
description = displayName,
picture = profile?.picture,
description = profile?.name ?: "No name",
size = 24.dp
)
},
@@ -372,13 +376,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 profileCache = LocalProfileCache.current
val profileFlow = remember(pubkey) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsState(initial = null)
SegmentedListItem(
selected = isSelected,
@@ -390,15 +390,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 ?: "Unknown",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
@@ -1,308 +1,26 @@
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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
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.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewIdentityScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
fun NewIdentityScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val accountState by viewModel.state.collectAsStateWithLifecycle()
val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false)
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
val scope = rememberCoroutineScope()
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 },
enabled = !isLoggedIn,
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 = !isLoggedIn,
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 = {
scope.launch {
try {
val imageBytes = withContext(Dispatchers.IO) {
picture?.let { uri ->
context.contentResolver.openInputStream(
uri
)?.use { input -> input.readBytes() }
}
}
val contentType =
picture?.let { context.contentResolver.getType(it) }
// Create the identity
viewModel.createIdentity(name, bio, imageBytes, contentType)
// Navigate to the home screen if successful
navigator.navigate(Screen.Home)
} catch (e: Exception) {
// Error is handled by viewModel.showError inside createIdentity
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoggedIn,
) {
if (isLoggedIn) {
LoadingIndicator()
} else {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
isBusy = accountState.isImporting,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
viewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
)
}
@@ -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,19 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -27,6 +34,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,22 +42,37 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
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.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen() {
fun OnboardingScreen(viewModel: AccountViewModel) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val accountState by viewModel.state.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
// Navigate to Home on successful external signer connection
LaunchedEffect(accountState.signerRequired) {
if (accountState.signerRequired == false) {
navigator.navigate(Screen.Home)
}
}
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 +165,41 @@ fun OnboardingScreen() {
)
}
Spacer(modifier = Modifier.size(8.dp))
OutlinedButton(
FilledTonalButton(
onClick = {
if (viewModel.isExternalSignerAvailable()) {
// Connect to the external signer
// TODO: show all available signers?
viewModel.connectExternalSigner()
} else {
scope.launch {
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()
@@ -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
@@ -45,36 +45,40 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalProfileCache
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
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileScreen(pubkey: String) {
fun ProfileScreen(
pubkey: String,
chatViewModel: ChatViewModel
) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val profileCache = LocalProfileCache.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) { profileCache.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 +87,7 @@ fun ProfileScreen(pubkey: String) {
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { },
title = { /* empty */ },
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
@@ -132,7 +136,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 +162,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 +181,7 @@ fun ProfileScreen(pubkey: String) {
}
Text(
text = "Message",
style = MaterialTheme.typography.labelSmall
style = MaterialTheme.typography.labelMedium
)
}
Column(
@@ -237,4 +242,4 @@ fun ProfileScreen(pubkey: String) {
}
}
)
}
}
@@ -3,48 +3,80 @@ 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 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 kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen() {
fun RelayScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
@@ -60,9 +92,30 @@ fun RelayScreen() {
}
}
var openAddRelayDialog by remember { mutableStateOf(false) }
var relayToDelete by remember { mutableStateOf<String?>(null) }
val accountState by viewModel.state.collectAsStateWithLifecycle()
val loadedRelayList = accountState.userRelayList
val loadedMsgRelayList = accountState.userMsgRelayList
LaunchedEffect(Unit) {
relayList.putAll(viewModel.currentUserRelayList())
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
viewModel.loadCurrentUserRelayList()
viewModel.loadCurrentUserMsgRelayList()
}
LaunchedEffect(loadedRelayList) {
if (loadedRelayList.isNotEmpty()) {
relayList.clear()
relayList.putAll(loadedRelayList)
}
}
LaunchedEffect(loadedMsgRelayList) {
if (loadedMsgRelayList.isNotEmpty()) {
msgRelayList.clear()
msgRelayList.addAll(loadedMsgRelayList)
}
}
Scaffold(
@@ -86,9 +139,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 +190,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 +311,216 @@ fun RelayScreen() {
}
}
)
}
if (openAddRelayDialog) {
AddRelayDialog(
viewModel = viewModel,
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 = {
if (msgRelayList.size == 1) {
scope.launch {
snackbarHostState.showSnackbar("You must have at least one relay")
}
relayToDelete = null
return@TextButton
}
viewModel.removeMsgRelay(relayToDelete!!)
msgRelayList.removeIf { it.toString() == relayToDelete }
relayToDelete = null
}
) {
Text("Confirm")
}
},
dismissButton = {
TextButton(onClick = { relayToDelete = null }) {
Text("Cancel")
}
}
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun AddRelayDialog(
viewModel: AccountViewModel,
onDismissRequest: () -> Unit,
onMsgRelayAdded: (newRelay: String) -> Unit,
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current
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 = {
if (!isError) {
when (selected) {
"Messaging" -> {
viewModel.addMsgRelay(relayAddress)
onMsgRelayAdded(relayAddress)
}
"Inbox" -> {
viewModel.addInboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.WRITE)
}
"Outbox" -> {
viewModel.addOutboxRelay(relayAddress)
onRelayAdded(relayAddress, RelayMetadata.READ)
}
}
onDismissRequest()
}
}) {
Icon(
painter = painterResource(Res.drawable.ic_check),
contentDescription = "Add"
)
}
},
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(innerPadding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedTextField(
value = relayAddress,
onValueChange = { relayAddress = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
isError = isError,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
}
),
singleLine = true,
label = { Text(text = "Relay Address") },
placeholder = { Text(text = "wss://relay.example.com") },
supportingText = {
if (isError) {
Text(text = "Invalid format. Must start with wss://")
} else {
Text(text = "Only add relays you trust.")
}
},
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Relay Roles",
style = MaterialTheme.typography.titleMediumEmphasized
)
Column(
modifier = Modifier
.fillMaxWidth()
.selectableGroup(),
) {
roles.forEach { text ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.selectable(
onClick = { onSelected(text) },
selected = (text == selected),
role = Role.RadioButton
)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = (text == selected),
onClick = null
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
}
)
}
}
fun verifyRelayUrl(url: String): Boolean {
return try {
RelayUrl.parse(url)
true
} catch (e: Exception) {
println("Failed to parse relay url: ${e.message}")
false
}
}
@@ -0,0 +1,152 @@
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.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 su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.RoomKind
import su.reya.coop.Screen
import su.reya.coop.viewmodel.ChatViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RequestListScreen(viewModel: ChatViewModel) {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
var isRefreshing by remember { mutableStateOf(false) }
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
// Get all request rooms
val requests = remember(chatRooms) {
chatRooms.filter { it.kind == RoomKind.Request }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surfaceContainer,
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
title = {
Text(
text = "New Requests",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
navigationIcon = {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (requests.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No requests yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "New chat requests will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(requests.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id, true)) }
)
}
}
}
}
}
}
}
}
@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -44,10 +43,10 @@ fun ScanScreen() {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
CenterAlignedTopAppBar(
title = {
Text(
text = "Scan QR",
text = "Scan a Nostr Address",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
@@ -97,14 +96,6 @@ fun ScanScreen() {
.align(Alignment.Center)
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
)
Text(
text = "Scan a Nostr address",
style = MaterialTheme.typography.titleSmallEmphasized,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 64.dp)
)
}
}
}
@@ -0,0 +1,291 @@
package su.reya.coop.screens
import android.content.Intent
import android.provider.Settings as AndroidSettings
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.MediaConfig
import su.reya.coop.Theme
import su.reya.coop.viewmodel.SettingsViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
val navigator = LocalNavigator.current
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val settings by viewModel.settings.collectAsState()
var showThemeDialog by remember { mutableStateOf(false) }
var showMediaDialog by remember { mutableStateOf(false) }
var showBlossomDialog by remember { mutableStateOf(false) }
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
text = "Settings",
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"
)
}
},
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "General",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
SegmentedListItem(
onClick = { viewModel.update { it.copy(screening = !it.screening) } },
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 4),
content = { Text("Screening") },
supportingContent = { Text("Filter unknown contacts") },
trailingContent = {
Switch(
checked = settings.screening,
onCheckedChange = { viewModel.update { s -> s.copy(screening = it) } }
)
}
)
SegmentedListItem(
onClick = { showMediaDialog = true },
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 4),
content = { Text("Media Preview") },
supportingContent = {
Text(
when (settings.media) {
MediaConfig.Disabled -> "Disabled"
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
MediaConfig.AlwaysEnabled -> "Always Enabled"
}
)
}
)
SegmentedListItem(
onClick = { showBlossomDialog = true },
shapes = ListItemDefaults.segmentedShapes(index = 2, count = 4),
content = { Text("Blossom Server") },
supportingContent = { Text(settings.blossomServer ?: "Default") }
)
SegmentedListItem(
onClick = {
val intent = Intent(AndroidSettings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName)
}
context.startActivity(intent)
},
shapes = ListItemDefaults.segmentedShapes(index = 3, count = 4),
content = { Text("Notifications") },
supportingContent = { Text("System notification settings") }
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Appearance",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 8.dp)
)
Column(
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)
) {
SegmentedListItem(
onClick = { showThemeDialog = true },
shapes = ListItemDefaults.segmentedShapes(index = 0, count = 2),
content = { Text("Theme") },
supportingContent = { Text(settings.theme.name) }
)
SegmentedListItem(
onClick = { viewModel.update { it.copy(dynamicColor = !it.dynamicColor) } },
shapes = ListItemDefaults.segmentedShapes(index = 1, count = 2),
content = { Text("Dynamic Color") },
trailingContent = {
Switch(
checked = settings.dynamicColor,
onCheckedChange = {
viewModel.update { s -> s.copy(dynamicColor = it) }
}
)
}
)
}
}
}
}
if (showThemeDialog) {
OptionDialog(
title = "Select Theme",
options = Theme.entries.map { it.name },
selected = settings.theme.name,
onSelected = { name ->
viewModel.update { it.copy(theme = Theme.valueOf(name)) }
showThemeDialog = false
},
onDismiss = { showThemeDialog = false }
)
}
if (showMediaDialog) {
OptionDialog(
title = "Media Preview",
options = listOf("Disabled", "Disabled for Mobile Data", "Always Enabled"),
selected = when (settings.media) {
MediaConfig.Disabled -> "Disabled"
MediaConfig.DisabledForMobileData -> "Disabled for Mobile Data"
MediaConfig.AlwaysEnabled -> "Always Enabled"
},
onSelected = { choice ->
val newConfig = when (choice) {
"Disabled" -> MediaConfig.Disabled
"Disabled for Mobile Data" -> MediaConfig.DisabledForMobileData
else -> MediaConfig.AlwaysEnabled
}
viewModel.update { it.copy(media = newConfig) }
showMediaDialog = false
},
onDismiss = { showMediaDialog = false }
)
}
if (showBlossomDialog) {
var text by remember { mutableStateOf(settings.blossomServer ?: "") }
AlertDialog(
onDismissRequest = { showBlossomDialog = false },
title = { Text("Blossom Server URL") },
text = {
OutlinedTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("https://...") },
singleLine = true
)
},
confirmButton = {
TextButton(onClick = {
viewModel.update { it.copy(blossomServer = text.ifBlank { null }) }
showBlossomDialog = false
}) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showBlossomDialog = false }) {
Text("Cancel")
}
}
)
}
}
@Composable
fun OptionDialog(
title: String,
options: List<String>,
selected: String,
onSelected: (String) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(modifier = Modifier.selectableGroup()) {
options.forEach { text ->
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(
selected = (text == selected),
onClick = { onSelected(text) },
role = Role.RadioButton
)
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = (text == selected), onClick = null)
Spacer(Modifier.size(16.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
}
}
}
},
confirmButton = {}
)
}
@@ -0,0 +1,30 @@
package su.reya.coop.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import su.reya.coop.LocalNavigator
import su.reya.coop.shared.ProfileEditor
import su.reya.coop.viewmodel.AccountViewModel
@Composable
fun UpdateProfileScreen(viewModel: AccountViewModel) {
val navigator = LocalNavigator.current
val currentUser by viewModel.currentUserProfile.collectAsStateWithLifecycle()
val profile = currentUser?.metadata?.asRecord()
val isUpdatingProfile by viewModel.isUpdatingProfile.collectAsStateWithLifecycle()
ProfileEditor(
title = "Update profile",
buttonLabel = "Save changes",
initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "",
initialPicture = profile?.picture,
isBusy = isUpdatingProfile,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
viewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
)
}
@@ -0,0 +1,172 @@
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.LocalProfileCache
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
import su.reya.coop.viewmodel.AccountViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ScreenerCard(viewModel: AccountViewModel, room: Room) {
val pubkey = room.members.firstOrNull() ?: return
val profileCache = LocalProfileCache.current
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) { profileCache.getMetadata(pubkey) }
val profile by profileFlow.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) {
// Check contact
viewModel.verifyContact(pubkey) { isContact = it }
// Get mutual contacts
viewModel.mutualContacts(pubkey) { mutualContacts = it }
// Get the last activity
viewModel.verifyActivity(pubkey) { lastActivity = it }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 48.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Avatar(
picture = profile?.picture,
description = "Profile picture",
modifier = Modifier.size(120.dp),
shape = MaterialShapes.Cookie12Sided.toShape(),
)
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = profile?.name ?: "No name",
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
Text(
text = pubkey.short(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
),
contentDescription = "Warning",
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Text(
text = if (isContact) "Contact" else "Not a contact",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
),
contentDescription = "Warning",
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
Text(
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_check_circle),
contentDescription = "Warning",
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
Text(
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
style = MaterialTheme.typography.labelMediumEmphasized
)
}
}
}
}
@Composable
fun DateSeparator(date: String) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = date,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
@@ -0,0 +1,105 @@
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.foundation.text.KeyboardOptions
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.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
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") },
maxLines = 5,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Default
),
leadingIcon = {
IconButton(onClick = onUpload) {
Icon(
painter = painterResource(Res.drawable.ic_add_circle),
contentDescription = "Upload",
)
}
},
)
Spacer(modifier = Modifier.size(8.dp))
AnimatedContent(
targetState = value.isNotEmpty(),
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
label = "send_mic_transition"
) { isNotEmpty ->
if (isNotEmpty) {
IconButton(
onClick = onSend,
modifier = Modifier.size(56.dp),
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Icon(
painter = painterResource(Res.drawable.ic_send),
contentDescription = "Send"
)
}
} else {
FilledTonalIconButton(
onClick = onMicClick,
modifier = Modifier.size(56.dp),
) {
Icon(
painter = painterResource(Res.drawable.ic_audio),
contentDescription = "Speech to Text"
)
}
}
}
}
}
@@ -0,0 +1,293 @@
package su.reya.coop.screens.chat
import androidx.compose.foundation.combinedClickable
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
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.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
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 androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import rust.nostr.sdk.EventId
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalConnectivity
import su.reya.coop.LocalSettings
import su.reya.coop.MediaConfig
import su.reya.coop.URL_REGEX
import su.reya.coop.formatAsTime
import su.reya.coop.isImageUrl
import su.reya.coop.removeImageUrls
@Immutable
data class ReactionGroup(
val emoji: String,
val authors: List<PublicKey>,
val containsMe: Boolean
)
@Immutable
data class MessageModel(
val id: EventId,
val author: PublicKey,
val annotatedContent: AnnotatedString,
val images: List<String>,
val timestamp: String,
val isMine: Boolean,
val replyEventIds: List<EventId>,
val reactions: List<ReactionGroup> = emptyList()
)
@Composable
fun rememberMessageModel(
event: UnsignedEvent,
reactions: List<UnsignedEvent> = emptyList(),
currentUser: PublicKey? = null
): MessageModel {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
return remember(event, reactions, currentUser, settings, isMobileData) {
val id = event.ensureId().id()!!
val isMine = currentUser == event.author()
val content = event.content()
val replyEventIds = event.tags().eventIds()
val showMedia = when (settings.media) {
MediaConfig.AlwaysEnabled -> true
MediaConfig.Disabled -> false
MediaConfig.DisabledForMobileData -> !isMobileData
}
val images = if (showMedia) {
URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList()
} else {
emptyList()
}
val cleanedContent = if (showMedia) content.removeImageUrls() else content
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(
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium
)
)
)
)
append(url)
pop()
lastIndex = matchResult.range.last + 1
}
append(cleanedContent.substring(lastIndex))
}
val groupedReactions = reactions.groupBy { it.content() }
.map { (emoji, events) ->
val authors = events.map { it.author() }
ReactionGroup(
emoji = emoji,
authors = authors,
containsMe = authors.any { it == currentUser }
)
}
MessageModel(
id = id,
author = event.author(),
annotatedContent = annotatedString,
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = isMine,
replyEventIds = replyEventIds,
reactions = groupedReactions
)
}
}
@Composable
fun ChatMessage(
model: MessageModel,
modifier: Modifier = Modifier,
onLongClick: (Rect) -> Unit = {}
) {
var isMessageClicked by remember { mutableStateOf(false) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
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
.onGloballyPositioned { layoutCoordinates = it }
.fillMaxWidth()
.padding(vertical = 4.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Column(
modifier = Modifier.combinedClickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { isMessageClicked = !isMessageClicked },
onLongClick = {
layoutCoordinates?.let { coords ->
onLongClick(coords.boundsInWindow())
}
}
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
Column(
modifier = Modifier.padding(
bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp,
end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp
),
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (model.annotatedContent.isNotBlank()) {
Surface(
modifier = Modifier.widthIn(max = 280.dp),
color = containerColor,
contentColor = contentColor,
shape = bubbleShape,
) {
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
)
}
}
}
if (model.reactions.isNotEmpty()) {
MessageReactions(
reactions = model.reactions,
modifier = Modifier.offset(y = 12.dp)
)
}
}
if (isMessageClicked) {
Text(
text = model.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp)
)
}
}
}
}
@Composable
private fun MessageReactions(
reactions: List<ReactionGroup>,
modifier: Modifier = Modifier
) {
val totalCount = reactions.sumOf { it.authors.size }
val displayEmojis = reactions.take(3).map { it.emoji }
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
displayEmojis.forEach { emoji ->
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = emoji,
fontSize = 12.sp,
)
}
}
}
if (totalCount > 2) {
Surface(
modifier = Modifier.size(24.dp),
color = MaterialTheme.colorScheme.surface,
shape = CircleShape,
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = totalCount.toString(),
style = MaterialTheme.typography.labelSmall,
fontSize = 10.sp,
)
}
}
}
}
}
@@ -0,0 +1,717 @@
package su.reya.coop.screens.chat
import android.app.Activity
import android.content.ClipData
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.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
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.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.width
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
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.MenuDefaults
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.mutableFloatStateOf
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.blur
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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_copy
import coop.composeapp.generated.resources.ic_reply
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.EventId
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalProfileCache
import su.reya.coop.LocalSettings
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.RoomUiState
import su.reya.coop.Screen
import su.reya.coop.formatAsGroup
import su.reya.coop.shared.Avatar
import su.reya.coop.uiStateFlow
import su.reya.coop.viewmodel.AccountViewModel
import su.reya.coop.viewmodel.ChatScreenViewModel
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatScreen(
viewModel: ChatScreenViewModel,
accountViewModel: AccountViewModel,
coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val navigator = LocalNavigator.current
val profileCache = LocalProfileCache.current
val settings = LocalSettings.current
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val id = viewModel.id
val currentUser by viewModel.currentUser.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
// Show empty screen
if (room == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Something went wrong.",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
}
return
}
val loading = viewModel.loading
val newOtherMessages = viewModel.newOtherMessages
val requireScreening = viewModel.requireScreening
val allEvents = viewModel.messages
val displayMessages by remember {
derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } }
}
val reactionsByMessage by remember {
derivedStateOf {
allEvents.filter { it.kind().asStd() == KindStandard.REACTION }
.groupBy { it.tags().eventIds().firstOrNull() }
}
}
val groupedMessages =
remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } }
val roomState by remember(id, currentUser?.publicKey) {
(room as Room).uiStateFlow(profileCache, currentUser?.publicKey)
}.collectAsStateWithLifecycle(RoomUiState())
var text by remember { mutableStateOf("") }
var selectedMessage by remember { mutableStateOf<Pair<MessageModel, Rect>?>(null) }
var replyingTo by remember { mutableStateOf<MessageModel?>(null) }
val blurAmount by animateDpAsState(
targetValue = if (selectedMessage != null) 8.dp else 0.dp,
label = "blurAnimation"
)
val goToMessage = { eventId: EventId? ->
if (eventId != null) {
scope.launch {
var targetIndex = -1
var currentIndex = 0
for (group in groupedMessages.value) {
val msgInGroup = group.value
val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId }
if (idx != -1) {
targetIndex = currentIndex + idx
break
}
currentIndex += msgInGroup.size + 1
}
if (targetIndex != -1) {
listState.animateScrollToItem(targetIndex)
}
}
}
}
val sendFile = { uri: Uri ->
scope.launch {
// Read file on IO dispatcher
val file = withContext(coroutineDispatcher) {
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
}
// Parse the file content type
val type = context.contentResolver.getType(uri)
// Send message (handles errors internally via ViewModel)
viewModel.sendFileMessage(file, type)
}
}
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(allEvents.size) {
if (displayMessages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
modifier = Modifier.blur(blurAmount),
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 && settings.screening) {
room?.let { ScreenerCard(accountViewModel, it) }
}
when (displayMessages.isNotEmpty()) {
true -> {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(16.dp),
) {
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items(
items = messagesInGroup,
key = { it.ensureId().id()?.toHex()!! }
) { event ->
val msgReactions = reactionsByMessage[event.id()] ?: emptyList()
val model =
rememberMessageModel(event, msgReactions, currentUser?.publicKey)
val replyPreview =
remember(model.replyEventIds, displayMessages.size) {
model.replyEventIds.firstOrNull()
?.let { replyId ->
displayMessages.find { it.ensureId().id() == replyId }
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
replyPreview?.let { previewEvent ->
ReplyPreview(
event = previewEvent,
isMine = model.isMine,
onClick = { goToMessage(previewEvent.id()) }
)
}
ChatMessage(
model = model,
modifier = Modifier.graphicsLayer {
alpha =
if (selectedMessage?.first?.id == model.id) 0f else 1f
},
onLongClick = { rect ->
selectedMessage = model to rect
}
)
}
}
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 && settings.screening) {
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 = { viewModel.requireScreening = false },
modifier = Modifier
.weight(1f)
.size(ButtonDefaults.MediumContainerHeight)
) {
Text(
text = "Accept",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
else -> {
AnimatedVisibility(visible = replyingTo != null) {
replyingTo?.let {
ReplyBox(it) { replyingTo = null }
}
}
ChatInput(
value = text,
onValueChange = { text = it },
onSend = {
viewModel.sendMessage(text, replyingTo?.id)
text = ""
replyingTo = null
},
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")
}
}
}
)
}
}
}
}
}
)
AnimatedVisibility(
visible = selectedMessage != null,
enter = fadeIn(),
exit = fadeOut()
) {
val (model, bounds) = selectedMessage ?: return@AnimatedVisibility
val density = LocalDensity.current
val scrollState = rememberScrollState()
var menuHeight by remember { mutableFloatStateOf(0f) }
var toolbarHeight by remember { mutableFloatStateOf(0f) }
val spacing = with(density) { 6.dp.toPx() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable { selectedMessage = null }
.verticalScroll(scrollState),
) {
val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) +
(if (toolbarHeight > 0) toolbarHeight + spacing else 100f)
val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() }
Spacer(modifier = Modifier.height(contentBottom + 200.dp))
// Reaction Toolbar (Above)
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) }
.onGloballyPositioned { toolbarHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ReactionToolbar(
onReaction = { reaction ->
viewModel.sendReaction(model.id, reaction)
selectedMessage = null
}
)
}
// Message Preview
ChatMessage(
model = model,
modifier = Modifier
.offset { IntOffset(0, bounds.top.toInt()) }
.padding(horizontal = 16.dp)
)
// Action Menu (Below)
Box(
modifier = Modifier
.offset { IntOffset(0, (bounds.bottom + spacing).toInt()) }
.onGloballyPositioned { menuHeight = it.size.height.toFloat() }
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
ContextMenu(
onAction = { action ->
when (action) {
"Copy" -> {
scope.launch {
val content = model.annotatedContent
val data = ClipData.newPlainText(content, content)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
"Reply" -> {
replyingTo = model
}
else -> {}
}
selectedMessage = null
}
)
}
}
}
}
}
@Composable
private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(model) { profileCache.getMetadata(model.author) }
val profile by profileFlow.collectAsStateWithLifecycle()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onDismiss() },
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(
text = "Replying to ${profile?.name}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(
alpha = 0.6f
),
)
Text(
text = model.annotatedContent.toString().ifBlank {
if (model.images.isNotEmpty()) "[Image]" else ""
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
}
@Composable
private fun ReplyPreview(
event: UnsignedEvent,
isMine: Boolean = false,
onClick: () -> Unit
) {
val profileCache = LocalProfileCache.current
val profileFlow = remember(event) { profileCache.getMetadata(event.author()) }
val profile by profileFlow.collectAsStateWithLifecycle()
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)
}
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) {
Surface(
modifier = Modifier
.widthIn(max = 280.dp)
.clickable(onClick = onClick),
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = bubbleShape,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = profile?.name ?: "Unknown",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text = event.content(),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer,
maxLines = 1,
)
}
}
}
}
@Composable
private fun ReactionToolbar(
onReaction: (String) -> Unit
) {
val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉")
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
shadowElevation = 1.dp
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
reactionEmojis.forEach { emoji ->
Text(
text = emoji,
modifier = Modifier
.clickable { onReaction(emoji) }
.padding(4.dp),
fontSize = 24.sp
)
}
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun ContextMenu(
onAction: (String) -> Unit
) {
val menuItems = listOf(
"Copy" to Res.drawable.ic_copy,
"Reply" to Res.drawable.ic_reply
)
DropdownMenuGroup(
shapes = MenuDefaults.groupShape(1, 1),
containerColor = MenuDefaults.groupVibrantContainerColor,
tonalElevation = 1.dp,
shadowElevation = 1.dp,
modifier = Modifier.width(220.dp)
) {
val itemCount = menuItems.size
menuItems.forEachIndexed { index, (label, icon) ->
DropdownMenuItem(
shapes = MenuDefaults.itemShape(index, itemCount),
colors = MenuDefaults.selectableItemVibrantColors(),
text = { Text(label) },
leadingIcon = {
Icon(
painter = painterResource(icon),
contentDescription = label,
)
},
checked = false,
onCheckedChange = { _ -> onAction(label) },
)
}
}
}
@@ -1,5 +1,6 @@
package su.reya.coop.shared
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
@@ -13,6 +14,9 @@ import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.avatar
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalConnectivity
import su.reya.coop.LocalSettings
import su.reya.coop.MediaConfig
@Composable
fun Avatar(
@@ -22,17 +26,36 @@ fun Avatar(
size: Dp = 48.dp,
shape: Shape = CircleShape
) {
val settings = LocalSettings.current
val isMobileData = LocalConnectivity.current
val placeholder = painterResource(Res.drawable.avatar)
AsyncImage(
model = picture,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop,
fallback = placeholder,
error = placeholder,
placeholder = placeholder
)
val showMedia = when (settings.media) {
MediaConfig.AlwaysEnabled -> true
MediaConfig.Disabled -> false
MediaConfig.DisabledForMobileData -> !isMobileData
}
if (showMedia) {
AsyncImage(
model = picture,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop,
fallback = placeholder,
error = placeholder,
placeholder = placeholder
)
} else {
Image(
painter = placeholder,
contentDescription = description,
modifier = modifier
.size(size)
.clip(shape),
contentScale = ContentScale.Crop
)
}
}
@@ -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.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileEditor(
title: String,
buttonLabel: String,
initialName: String = "",
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false,
onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit,
ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) {
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) }
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 {
try {
val bytes = withContext(ioDispatcher) {
(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")
}
}
},
enabled = name.isNotBlank() && !isBusy
) {
if (isBusy) {
LoadingIndicator()
} else {
Text(
text = buttonLabel,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
}
@@ -1,40 +0,0 @@
package su.reya.coop.shared
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import su.reya.coop.NostrViewModel
import su.reya.coop.Room
import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
// Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (displayMembers.isEmpty()) return flowOf("Unknown")
return combine(displayMembers.map { viewModel.getMetadata(it) }) { metadataArray ->
val names = metadataArray.mapIndexed { i, metadata ->
val profile = metadata?.asRecord()
profile?.name?.takeIf { it.isNotBlank() }
?: profile?.displayName?.takeIf { it.isNotBlank() }
?: displayMembers[i].short()
}
if (isGroup()) {
val combined = names.joinToString(", ")
val extraCount = members.size - names.size
if (extraCount > 0) "$combined, +$extraCount" else combined
} else {
val name = names.first()
if (displayMembers.first() == viewModel.currentUser()) "$name (you)" else name
}
}
}
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
val firstMember = members.firstOrNull() ?: return flowOf(null)
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}
@@ -1,43 +0,0 @@
package su.reya.coop.coop.storage
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import su.reya.coop.storage.SecretStorage
private val Context.dataStore by preferencesDataStore("secret_store")
class SecretStore(private val context: Context) : SecretStorage {
private val crypto = SecretCrypto()
override suspend fun set(key: String, value: String) {
val entry = crypto.encrypt(value)
context.dataStore.edit { prefs ->
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
}
}
override suspend fun get(key: String): String? {
val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
return crypto.decrypt(SecretEntry(encrypted, iv))
}
override suspend fun clear(key: String) {
context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${key}_iv"))
}
}
override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first()
return prefs[stringPreferencesKey("${key}_encrypted")] != null
}
}
+3 -3
View File
@@ -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" }
+8 -3
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,13 @@ 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(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore)
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)
@@ -0,0 +1,14 @@
package su.reya.coop
interface AppStorage {
// Plain text storage
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
// Encrypted storage
suspend fun getSecret(key: String): String?
suspend fun setSecret(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}
@@ -0,0 +1,7 @@
package su.reya.coop
import kotlinx.coroutines.flow.StateFlow
interface ConnectivityMonitor {
val isMobileData: StateFlow<Boolean>
}
@@ -0,0 +1,24 @@
package su.reya.coop
import rust.nostr.sdk.PublicKey
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp", "avif")
fun String.removeImageUrls(): String {
return URL_REGEX.replace(this) { if (it.value.isImageUrl()) "" else it.value }.trim()
}
fun String.isImageUrl(): Boolean {
val extension = this.substringAfterLast('.', "").lowercase()
return extension in imageExtensions
}
fun String.sanitizeName(): String {
return this.replace("\n", " ").replace("\r", " ").trim()
}
fun PublicKey.short(): String {
val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
}
@@ -1,839 +0,0 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Alphabet
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Client
import rust.nostr.sdk.ClientBuilder
import rust.nostr.sdk.ClientNotification
import rust.nostr.sdk.Contact
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Filter
import rust.nostr.sdk.GossipConfig
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.LogLevel
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.MetadataRecord
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.Nip05Profile
import rust.nostr.sdk.NostrDatabase
import rust.nostr.sdk.NostrGossip
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayMessageEnum
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.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
import kotlin.time.Duration.Companion.milliseconds
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)
// Initialize the database and gossip instance
val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory()
// Set the idle timeout for relays
val idleTimeout = Duration.parse("5m")
client =
ClientBuilder()
.signer(signer)
.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 = "gift-wraps"
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
}
}
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit,
) = supervisorScope {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@supervisorScope
var eoseTrackerJob: Job? = null
while (true) {
val notification = notifications.next() ?: continue
when (notification) {
is ClientNotification.Message -> {
val relayUrl = notification.relayUrl
when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> {
val event = message.event
// Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
try {
val metadata = Metadata.fromJson(event.content())
onMetadataUpdate(event.author(), metadata)
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
}
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
if (isSignedByUser(event = event)) {
onContactListUpdate(event.tags().publicKeys())
}
}
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
// Get all gift wrap events for the current user
if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event)
}
}
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
val rumor = extractRumor(event)
// Logic to notify UI after processing
// Cancel previous tracker if it exists
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
}
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
} catch (e: Exception) {
println("Failed to extract rumor: $e")
}
}
}
is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId
if (subscriptionId == "gift-wraps") {
onSubscriptionClose()
}
}
is RelayMessageEnum.Ok -> {
if (sentEvents.containsKey(message.eventId)) {
val currentRelays = sentEvents[message.eventId] ?: emptyList()
sentEvents[message.eventId] = currentRelays + relayUrl
}
}
else -> {
/* Ignore other message types */
}
}
}
is ClientNotification.Shutdown -> {
break
}
else -> {
/* Ignore other message types */
}
}
}
}
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
try {
val filter = Filter().identifier(giftId.toHex())
val event = client?.database()?.query(filter)?.first()
return event?.content()?.let { UnsignedEvent.fromJson(it) }
} catch (e: Exception) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
}
}
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
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("14"))
)
// 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 {
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(100u)
val events = client?.database()?.query(filter)
val results = mutableMapOf<PublicKey, Metadata>()
events?.toVec()?.forEach { event ->
try {
val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata
} catch (e: Exception) {
println("Failed to parse metadata: $e")
}
}
return results
} catch (e: Exception) {
println("Failed to get all cache metadata: ${e.message}")
return emptyMap()
}
}
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
try {
val limit = keys.size.toULong() * 4u;
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
// Construct a filter for metadata events
val filter = Filter()
.kind(Kind.fromStd(KindStandard.METADATA))
.authors(keys)
.limit(limit)
// Construct a target that includes all filters
val target =
ReqTarget.manual(
mapOf(
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
RelayUrl.parse("wss://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).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 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()) }
// 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>): 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: Set<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 ->
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
.privateMsgRumor(receiver = receiver, message = content)
.tags(tags)
.allowSelfTagging()
.build(currentUser)
// Ensure the event ID is set
.ensureId()
// Emit the rumor to the chat screen
if (receiver == currentUser) {
onRumorCreated?.invoke(rumor)
}
// Construct the gift wrap event
val gift = giftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom(TagKind.Unknown("k"), listOf("14"))
)
)
// Send the event to receiver's NIP-17 relays
val output = client?.sendEvent(
event = gift,
target = SendEventTarget.toNip17(),
ackPolicy = AckPolicy.none(),
authenticationTimeout = Duration.parse("2s")
)
if (output != null) {
// Keep track of sent events
sentEvents[output.id] = emptyList()
if (rumor.id() != null) rumorMap[rumor.id()!!] = output.id
// Collect failed outputs
output.failed.forEach { (relayUrl, reason) ->
println("Failed to send event to relay $relayUrl: $reason")
}
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
try {
val response: HttpResponse = client.get(address.url())
val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
}
}
suspend fun searchByAddress(query: String): PublicKey {
try {
val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address)
return profile.publicKey()
} catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e)
}
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
// Add search relay
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
if (client?.relay(searchRelay) == null) {
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
client?.connectRelay(searchRelay)
}
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u)
val target =
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
val stream = client?.streamEvents(
target = target,
id = "search",
timeout = Duration.parse("4s"),
policy = ReqExitPolicy.ExitOnEose
)
// Collect the results
val results = mutableListOf<PublicKey>()
// Keep searching until the stream is closed or timeout
stream?.next()?.let { event ->
if (event.event != null) {
results.add(event.event!!.author())
}
}
return results
} catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
}
}
}
@@ -1,614 +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.milliseconds
import kotlin.time.Duration.Companion.seconds
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
private val _signerRequired = MutableStateFlow<Boolean?>(null)
val signerRequired = _signerRequired.asStateFlow()
private val _isLoggedIn = MutableStateFlow(false)
val isLoggedIn = _isLoggedIn.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 = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
val errorEvents = _errorEvents.receiveAsFlow()
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// 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)
}
}
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
_isNotificationBannerDismissed.value =
secretStore.get("notification_banner_dismissed") == "true"
}
}
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.milliseconds) {
metadataRequestChannel.receive()
}
if (nextKey != null) {
batch.add(nextKey)
}
val now = Clock.System.now().toEpochMilliseconds()
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.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()) {
mergeChatRooms(rooms)
_isPartialProcessedGiftWrap.value = true
}
// Get all metadata for the current user
nostr.getUserMetadata()
// Small delay to ensure all relays are connected
delay(3000.milliseconds)
// Check if the relay list is empty
val relays = nostr.getMsgRelays(pubkey)
if (relays.isEmpty()) {
_isRelayListEmpty.value = true
}
break
}
delay(500.milliseconds)
}
}
}
private fun requestMetadata(pubkey: PublicKey) {
if (seenPublicKeys.add(pubkey)) {
viewModelScope.launch {
metadataRequestChannel.send(pubkey)
}
}
}
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
}
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
if (flow.value == null) {
requestMetadata(pubkey)
}
return flow.asStateFlow()
}
fun currentUser(): PublicKey? {
return nostr.signer.currentUser
}
fun logout() {
viewModelScope.launch {
secretStore.clear("user_signer")
nostr.signer.switch(Keys.generate())
_signerRequired.value = true
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set("notification_banner_dismissed", "true")
_isNotificationBannerDismissed.value = true
}
}
fun dismissRelayWarning() {
_isRelayListEmpty.value = false
}
private suspend fun getOrInitAppKeys(): Keys {
val secret = secretStore.get("app_keys")
// If app keys are already stored, use them
if (secret != null) {
return Keys.parse(secret)
}
// Generate new app keys and save to the secret storage
val keys = Keys.generate()
secretStore.set("app_keys", keys.secretKey().toBech32())
return keys
}
private suspend fun 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")
}
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
_isLoggedIn.value = true
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
var avatarUrl = ""
// 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}")
} finally {
_isLoggedIn.value = true
}
}
suspend fun verifyIdentity(secret: String): PublicKey? {
try {
val signer = createSigner(secret)
if (secret.startsWith("bunker://")) {
showError("Please approve the connection.")
}
return signer.getPublicKeyAsync()
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun importIdentity(secret: String) {
_isLoggedIn.value = true
try {
val signer = createSigner(secret)
nostr.setSigner(signer)
secretStore.set("user_signer", secret)
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_signerRequired.value = false
_isLoggedIn.value = false
}
}
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).sortedDescending().toSet()
}
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room {
return chatRooms.value.firstOrNull { it.id == id }
?: throw IllegalArgumentException("Room not found")
}
private fun mergeChatRooms(rooms: Set<Room>) {
_chatRooms.update { currentRooms ->
val merged = currentRooms.associateBy { it.id }.toMutableMap()
// Add or update rooms from the database
rooms.forEach { room ->
merged[room.id] = room
}
// Return as a sorted set to maintain UI consistency
merged.values.sortedDescending().toSet()
}
}
fun getChatRooms() {
viewModelScope.launch {
val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
}
}
suspend fun refreshChatRooms() {
try {
val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
try {
return nostr.getChatRoomMessages(roomId)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
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,
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)
}
@@ -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?.sanitizeName() ?: record.name ?: publicKey.short()
val picture: String?
get() = record.picture
val shortPublicKey: String
get() = publicKey.short()
}
@@ -1,14 +1,17 @@
package su.reya.coop
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.ProfileCache
import kotlin.time.Clock
import kotlin.time.Instant
@@ -27,17 +30,17 @@ data class Room(
val subject: String?,
val members: Set<PublicKey>,
val kind: RoomKind = RoomKind.default(),
val lastMessage: String? = null
val lastMessage: String? = null,
val unreadCount: Int = 0
) : Comparable<Room> {
override fun compareTo(other: Room): Int {
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
}
companion object {
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId()
fun new(rumor: UnsignedEvent, userPubkey: PublicKey, id: Long = rumor.roomId()): Room {
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()
@@ -60,24 +63,48 @@ data class Room(
}
}
fun setKind(kind: RoomKind): Room {
return this.copy(kind = kind)
fun isGroup(): Boolean = members.size > 1
}
data class RoomUiState(
val name: String = "Loading...",
val picture: String? = null,
val isGroup: Boolean = false
)
fun Room.uiStateFlow(
profileCache: ProfileCache,
currentUser: PublicKey? = null
): Flow<RoomUiState> {
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (!subject.isNullOrBlank()) {
return flowOf(RoomUiState(name = subject.sanitizeName(), isGroup = isGroup()))
}
fun setCreatedAt(createdAt: Timestamp): Room {
return this.copy(createdAt = createdAt)
}
return combine(displayMembers.map { profileCache.getMetadata(it) }) { profiles ->
val names = profiles.mapIndexed { i, profile ->
profile?.name?.sanitizeName() ?: displayMembers[i].short()
}
fun setSubject(subject: String?): Room {
return this.copy(subject = subject)
}
val name = when {
isGroup() -> {
val combined = names.joinToString(", ")
val extra = members.size - names.size
if (extra > 0) "$combined, +$extra" else combined
}
fun setLastMessage(message: String?): Room {
return this.copy(lastMessage = message)
}
else -> {
val first = names.firstOrNull() ?: "Unknown"
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
}
}
fun isGroup(): Boolean {
return members.size > 1
RoomUiState(
name = name,
picture = profiles.firstOrNull()?.picture,
isGroup = isGroup()
)
}
}
@@ -86,30 +113,29 @@ fun UnsignedEvent.roomId(): Long {
val pubkeys: MutableList<PublicKey> = mutableListOf()
pubkeys.add(this.author())
pubkeys.addAll(this.tags().publicKeys())
return pubkeys.map { it.toBech32() }.distinct().sorted().hashCode().toLong()
}
// Sort and hash the list of public keys
val sortedUniqueKeys = pubkeys
.distinctBy { it.toBech32() }
.sortedBy { it.toBech32() }
return sortedUniqueKeys.hashCode().toLong()
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 SECONDS_IN_MINUTE = 60L
val MINUTES_IN_HOUR = 60L
val HOURS_IN_DAY = 24L
val DAYS_IN_MONTH = 30L
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 =
@@ -120,7 +146,7 @@ fun Timestamp.ago(): String {
}
}
fun Timestamp.formatAsGroupHeader(): String {
fun Timestamp.formatAsGroup(): String {
val timeZone = TimeZone.currentSystemDefault()
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
val inputDate = inputInstant.toLocalDateTime(timeZone).date
@@ -0,0 +1,22 @@
package su.reya.coop
import kotlinx.serialization.Serializable
@Serializable
data class Settings(
val theme: Theme = Theme.System,
val dynamicColor: Boolean = true,
val media: MediaConfig = MediaConfig.AlwaysEnabled,
val screening: Boolean = true,
val blossomServer: String? = "https://blossom.band",
)
@Serializable
enum class Theme {
Light, Dark, System
}
@Serializable
enum class MediaConfig {
Disabled, DisabledForMobileData, AlwaysEnabled
}
@@ -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)
@@ -0,0 +1,138 @@
package su.reya.coop.concord
import kotlinx.serialization.decodeFromString
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Instant
/**
* Folding the Control Plane (CORD-04 §1).
*
* The Control Plane carries the Community's authoritative state as **editions**: each is a
* `kind 3308` rumor naming its entity (`vsk`), that entity's stable coordinate (`eid`), this
* edition's version (`ev`) and the hash of the previous one (`ep`). Every member folds the whole
* chain and reaches the same verdict, so authority is arithmetic rather than a server's say-so.
*
* v1 folds only two entity types: Community metadata (`vsk 0`) and Channel metadata (`vsk 2`).
*
* ## What v1 does not do
*
* **The fold is not gated on authority.** CORD-04 judges every edition by its actor's rank in the
* owner-rooted Roster, via the `vac` citation. v1 has no Roster, so a `control_root` holder could
* publish a forged metadata or Channel edition and v1 would display it.
*
* That gap is bounded rather than open only the owner and staff hold `control_root` (CORD-02 §2),
* and the spec itself calls that secret "a spam gate, never authority" but it is real. It is the
* reason the feature ships labelled beta. Closing it is CORD-04's job: fold `vsk 1` (Roles) and
* `vsk 3` (Grants), then require every edition's `vac` to cite a Grant whose actor strictly
* outranks the entity it edits.
*/
object ConcordControl {
/**
* Parses one Control rumor into an edition, or null when it is not a foldable edition.
*
* A `vsk 10` Dissolution tombstone is refused here: it is chainless (no `ev`, no `ep`) and v1
* does not implement dissolution, so treating it as an ordinary edition would misread it.
*/
fun editionOf(rumor: UnsignedEvent): ControlEdition? {
if (rumor.kind().asU16() != ConcordKind.CONTROL_EDITION.toUShort()) return null
val tags = rumor.tags().toVec()
val vsk = tags.firstOrNull { it.kind() == ConcordTag.VSK }?.content()?.toIntOrNull() ?: return null
if (vsk == ConcordVsk.DISSOLVED) return null
val eidHex = tags.firstOrNull { it.kind() == ConcordTag.EID }?.content() ?: return null
if (eidHex.hex32() == null) return null
val version = tags.firstOrNull { it.kind() == ConcordTag.EV }?.content()?.toULongOrNull() ?: return null
// CORD-04: versions climb from 1. A zero version has no place in the chain.
if (version == 0uL) return null
val prev = tags.firstOrNull { it.kind() == ConcordTag.EP }?.content()
if (prev != null && prev.hex32() == null) return null
return ControlEdition(
vsk = vsk,
eidHex = eidHex.lowercase(),
version = version,
prevHashHex = prev?.lowercase(),
content = rumor.content(),
actorHex = rumor.author().toHex(),
rumorIdHex = rumor.id()?.toHex() ?: return null,
createdAt = Instant.fromEpochSeconds(rumor.createdAt().asSecs().toLong()),
)
}
/**
* Projects the editions into the metadata and Channel list v1 renders.
*
* Entities are folded independently: each `(vsk, eid)` group resolves to its own winner, so a
* broken chain on one Channel never takes the Community's metadata down with it.
*/
fun fold(editions: List<ControlEdition>, communityIdHex: String): ControlFold {
var meta: CommunityMeta? = null
val channels = mutableMapOf<String, ChannelMeta>()
editions.groupBy { it.vsk to it.eidHex }.forEach { (entity, group) ->
val (vsk, eidHex) = entity
if (vsk != ConcordVsk.METADATA && vsk != ConcordVsk.CHANNEL) return@forEach
// The metadata entity *is* the Community, so its coordinate must be the community_id;
// an edition naming anything else is not this Community's metadata.
if (vsk == ConcordVsk.METADATA && eidHex != communityIdHex) return@forEach
val winner = winner(group) ?: return@forEach
when (vsk) {
ConcordVsk.METADATA -> meta = decode(winner.content)
else -> decode<ChannelMeta>(winner.content)?.let { channels[eidHex] = it }
}
}
return ControlFold(meta = meta, channels = channels)
}
/**
* Picks the highest version whose `ep` chain reaches all the way back to version 1.
*
* "Highest" is not simply `max(ev)`: CORD-04 lets a client refuse to downgrade, so we walk down
* from the top and take the first version we can actually verify. A missing predecessor or a
* broken link disqualifies that version, not the entity.
*
* Ties on the same version break on the **lower rumor id**, never on `created_at`, so two
* clients folding the same two candidates always agree.
*/
private fun winner(group: List<ControlEdition>): ControlEdition? {
val byVersion = group
.groupBy { it.version }
.mapValues { (_, candidates) -> candidates.minByOrNull { it.rumorIdHex } ?: return null }
val eid = byVersion.values.firstOrNull()?.eidHex?.hex32() ?: return null
for (top in byVersion.keys.sortedDescending()) {
var prev: ByteArray? = null
var candidate: ControlEdition? = null
var intact = true
for (version in 1uL..top) {
val edition = byVersion[version]
if (edition == null) {
intact = false
break
}
// The first edition has no predecessor; every later one must chain to the last.
val expected = if (version == 1uL) null else prev?.toHex()
if (edition.prevHashHex != expected) {
intact = false
break
}
prev = editionHash(eid, version, prev, edition.content.encodeToByteArray())
candidate = edition
}
if (intact) return candidate
}
return null
}
private inline fun <reified T> decode(content: String): T? =
runCatching { concordJson.decodeFromString<T>(content) }.getOrNull()
}
@@ -0,0 +1,250 @@
package su.reya.coop.concord
import okio.Buffer
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ByteString.Companion.toByteString
import rust.nostr.sdk.Keys
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.SecretKey
/**
* Byte-exact cryptographic primitives from Concord (CORD-02 Appendix A).
*
* Everything here is frozen by the spec, and a single wrong byte breaks interop silently
* rather than loudly so each function quotes the CORD section that governs it, and each was
* checked against RFC 5869 vectors and independently computed digests before it was written.
* Concord ships no test vectors of its own ("Examples are illustrative, not verifiable test
* vectors"), and no test file is kept — see PLAN.md §14.
*
* Only HMAC-SHA256 and SHA-256 come from outside: both are Okio `ByteString` members that
* are available on every target this module builds for, so no new crypto dependency is
* needed. Everything else is composition of the existing nostr SDK.
*/
/**
* HKDF-SHA256 (RFC 5869), Extract then Expand.
*
* Concord always calls this with no salt (CORD-02 A.1 specifies a zero-length salt, not 32
* zero bytes), so [salt] defaults to empty. It is exposed only so the RFC's known-answer
* vectors which do use a salt can be re-checked by hand; Concord publishes none.
*
* @param length output length in octets, `1..255 * 32` per RFC 5869.
*/
fun hkdfSha256(
ikm: ByteArray,
info: ByteArray,
salt: ByteArray = ByteArray(0),
length: Int = 32,
): ByteArray {
require(length in 1..255 * 32) { "HKDF output length must be 1..8160, was $length" }
// Extract: PRK = HMAC-SHA256(salt, IKM). Note IKM is the HMAC *message*, not the key.
// Okio refuses a zero-length HMAC key, while RFC 5869 treats an absent salt as HashLen
// (32) zero octets — and HMAC zero-pads any key shorter than its 64-octet block, so the
// two are literally the same key. Substituting is exact, not a workaround; the RFC's own
// zero-length-salt vector was checked against it.
val saltKey = if (salt.isEmpty()) ByteArray(32).toByteString() else salt.toByteString()
val prk = ikm.toByteString().hmacSha256(saltKey)
// Expand: T(n) = HMAC-SHA256(PRK, T(n-1) | info | n), counter being one octet.
val out = Buffer()
var t = ByteString.EMPTY
var counter = 1
while (out.size < length) {
t = Buffer()
.write(t)
.write(info)
.writeByte(counter)
.readByteString()
.hmacSha256(prk)
out.write(t)
counter++
}
return out.readByteArray(length.toLong())
}
/**
* Builds the HKDF `info` for a Concord label (CORD-02 A.1):
*
* ```
* info = utf8(label) | 0x00 | id[32] | epoch_be[8] // epoch omitted for labels marked "—"
* ```
*
* [id] is always present and always 32 bytes, all-zeroes where a label has no meaningful
* id. The epoch is the only omittable field.
*/
fun hkdfInfo(label: String, id: ByteArray, epoch: ULong? = null): ByteArray {
require(id.size == 32) { "Concord HKDF id must be 32 bytes, was ${id.size}" }
return Buffer().apply {
writeUtf8(label)
writeByte(0)
write(id)
if (epoch != null) writeLong(epoch.toLong())
}.readByteArray()
}
/** A plane's derived keypair: `(sk, xonly(sk))` from CORD-02 A.2 `group_key`. */
data class GroupKey(val secretKey: SecretKey, val publicKey: PublicKey)
/**
* The secret-key material of a plane's group key: CORD-02 A.2's `group_key` up to and
* including A.3's `scalar_normalize`.
*
* ```
* info = hkdfInfo(label, id, epoch)
* seed = hkdf(secret, info)
* while (!isValidScalar(seed)) { info = info | counter++; seed = hkdf(secret, info) }
* ```
*
* A.3 only bites when the HKDF output is not a secp256k1 scalar, which is ~2¹² rare, so
* [isValid] exists as a seam for tests; production callers pass the default.
*
* This is deliberately split from [groupKey]: it is pure byte manipulation and so is
* unit-testable, whereas the secp256k1 half needs the nostr SDK, whose native library
* cannot be loaded by a host JVM unit test (see PLAN.md §13.2).
*/
fun groupSeed(
label: String,
secret: ByteArray,
id: ByteArray,
epoch: ULong? = null,
isValid: (ByteArray) -> Boolean = ::isValidScalar,
): ByteArray {
val base = hkdfInfo(label, id, epoch)
var counter = -1 // -1 means "no counter byte", i.e. the first attempt
while (counter <= 255) {
val info = if (counter < 0) base else base + byteArrayOf(counter.toByte())
val seed = hkdfSha256(secret, info)
if (isValid(seed)) return seed
counter++ // A.3: the counter starts at 0 on the first retry
}
error("Concord group_key: scalar_normalize exhausted for label $label")
}
/**
* A secp256k1 secret key is any integer in `[1, n-1]`, so CORD-02 A.3's validity test rejects
* exactly the all-zeroes seed and any seed not below the group order.
*/
fun isValidScalar(seed: ByteArray): Boolean {
if (seed.size != 32) return false
var anyNonZero = false
for (byte in seed) {
if (byte != 0.toByte()) {
anyNonZero = true
break
}
}
if (!anyNonZero) return false
for (i in 0 until 32) {
val candidate = seed[i].toInt() and 0xff
val order = SECP256K1_ORDER[i].toInt() and 0xff
if (candidate != order) return candidate < order
}
return false // exactly n, also out of range
}
private val SECP256K1_ORDER =
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141".hexToBytes()
/**
* `group_key` (CORD-02 A.2): a plane's keypair, `(scalar_normalize(seed), xonly_pubkey(sk))`.
*
* The `conv_key` of A.2 needs no implementation of its own it *is* the NIP-44 conversation
* key, which the SDK derives from the pair returned here, so nothing here hand-rolls ECDH.
*/
fun groupKey(label: String, secret: ByteArray, id: ByteArray, epoch: ULong? = null): GroupKey {
val secretKey = SecretKey.fromBytes(groupSeed(label, secret, id, epoch))
return GroupKey(secretKey, Keys(secretKey).publicKey())
}
/**
* `community_id` (CORD-02 A.4), which is also the community's self-certification:
*
* ```
* community_id = sha256( utf8("concord/community") | owner_xonly[32] | owner_salt[32] )
* ```
*
* Note this is a plain SHA-256 commitment with **no** `0x00` separator and no length
* prefix it is deliberately *not* the HKDF construction of A.1, despite looking like it.
*/
fun communityId(ownerXonly: ByteArray, ownerSalt: ByteArray): ByteArray {
require(ownerXonly.size == 32) { "owner_xonly must be 32 bytes, was ${ownerXonly.size}" }
require(ownerSalt.size == 32) { "owner_salt must be 32 bytes, was ${ownerSalt.size}" }
return Buffer().apply {
writeUtf8(ConcordLabel.COMMUNITY)
write(ownerXonly)
write(ownerSalt)
}.readByteString().sha256().toByteArray()
}
/**
* `prevcommit` (CORD-02 A.8) the commitment to the previous epoch's key, published when
* an epoch rolls so that members can verify the rotation chained from what they held:
*
* ```
* prevcommit = sha256( utf8("concord/epoch-key-commitment") | prev_epoch_be[8] | prev_key[32] )
* ```
*/
fun prevCommit(prevEpoch: ULong, prevKey: ByteArray): ByteArray {
require(prevKey.size == 32) { "prev_key must be 32 bytes, was ${prevKey.size}" }
return Buffer().apply {
writeUtf8(ConcordLabel.EPOCH_KEY_COMMITMENT)
writeLong(prevEpoch.toLong())
write(prevKey)
}.readByteString().sha256().toByteArray()
}
/**
* `edition_hash` (CORD-02 A.8) links a Control edition to its predecessor, so a client
* folding the Control plane can detect a rewritten chain:
*
* ```
* edition_hash = sha256(
* len64(label) | label // label = ConcordLabel.EDITION_HASH
* | entity_id[32]
* | version_be[8]
* | (prev ? 0x01 | prev[32] : 0x00 | zero[32])
* | len64(content) | content ) // content bytes verbatim, never re-serialized
* ```
*
* [content] must be the exact bytes that were signed re-serializing the JSON would change
* the hash.
*/
fun editionHash(entityId: ByteArray, version: ULong, prev: ByteArray?, content: ByteArray): ByteArray {
require(entityId.size == 32) { "entity_id must be 32 bytes, was ${entityId.size}" }
require(prev == null || prev.size == 32) { "prev must be 32 bytes" }
val label = ConcordLabel.EDITION_HASH.encodeToByteArray()
return Buffer().apply {
writeLong(label.size.toLong())
write(label)
write(entityId)
writeLong(version.toLong())
if (prev != null) {
writeByte(1)
write(prev)
} else {
writeByte(0)
write(ByteArray(32))
}
writeLong(content.size.toLong())
write(content)
}.readByteString().sha256().toByteArray()
}
/** 64 lowercase hex chars, the encoding CORD-01 mandates for every 32-byte value on the wire. */
internal fun ByteArray.toHex(): String = toByteString().hex()
/** Inverse of [toHex]. Throws on odd length or a non-hex character. */
internal fun String.hexToBytes(): ByteArray = decodeHex().toByteArray()
/** Inverse of [toHex], returning null instead of throwing — for reading untrusted input. */
internal fun String.hexToBytesOrNull(): ByteArray? = runCatching { hexToBytes() }.getOrNull()
/**
* A 32-byte value read from hex, or null when it is not exactly 32 bytes.
*
* The length check is the point: most of Concord's ids and keys are 32 bytes, and a short or long
* value must be rejected before it reaches a derivation that assumes a fixed width.
*/
internal fun String.hex32(): ByteArray? = if (length == 64) hexToBytesOrNull() else null
@@ -0,0 +1,253 @@
package su.reya.coop.concord
import kotlin.io.encoding.Base64
import rust.nostr.sdk.Nip19Coordinate
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.nip44Decrypt
/**
* CORD-05: redeeming an invite.
*
* Two ways to be handed the keys, one bundle:
*
* - **Public link** `$BASE/invite/<naddr>#<fragment>`. The naddr names where the encrypted
* bundle sits on relays; the fragment carries an off-network unlock token and never reaches a
* server. The bundle is fetched, then decrypted with a key derived from the token.
* - **Direct Invite** the same bundle, giftwrapped straight to an npub. Nothing to fetch. That
* path needs only [decryptInviteBundle]; the arrival is handled in `ConcordManager.onInboxRumor`.
*
* Minting is out of v1's scope, so this file only decodes.
*
* A bundle is attacker-crafted input reached by following a link, so nothing here allocates on the
* strength of what the bundle claims (CORD-05 §1).
*/
/** The stock relay dictionary (CORD-05 §3). Referenced by one byte so links stay short. */
internal object ConcordRelayDictionary {
val STOCK = listOf(
"wss://jskitty.com/nostr",
"wss://asia.vectorapp.io/nostr",
"wss://relay.ditto.pub",
"wss://relay.dreamith.to",
)
}
/** The link's secret half: `[version][flags][relays?][token:16]`, base64url without padding. */
data class InviteFragment(
val version: Int,
val relays: List<String>,
/** The 16-byte unlock token, hex. Derives exactly one thing: the bundle key. */
val tokenHex: String,
)
/** A parsed invite link: the public locator plus the secret fragment. */
data class ParsedInvite(
val signerHex: String,
val identifier: String,
/** Relays the naddr itself names. Usually empty; the compact form travels in the fragment. */
val naddrRelays: List<RelayUrl>,
val fragment: InviteFragment,
)
private const val FRAGMENT_VERSION = 4
/** The one flag v1 knows: the stock set is in use, so zero relay bytes follow. */
private const val FLAG_STOCK_RELAYS = 1
private const val TOKEN_BYTES = 16
/** CORD-05 §3: the fragment only needs to *find* the bundle; the bundle carries the real set. */
private const val MAX_BOOTSTRAP_RELAYS = 3
/** CORD-05 §1: the spec's reference ceiling on a bundle's Channel list. */
private const val MAX_INVITE_CHANNELS = 256
/** CORD-02 §6: five stable relays is the recommendation, not a rule; a client may truncate. */
private const val MAX_COMMUNITY_RELAYS = 5
/** The fragment is unpadded base64url; ABSENT_OPTIONAL also tolerates a padded paste. */
private val fragmentBase64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL)
/**
* Decodes a link into its locator and fragment, or null when it is not a Concord invite.
*
* The base domain is deliberately ignored CORD-05 §2 makes the base interchangeable and says any
* client recognizing an invite must respect the naddr and fragment verbatim so only the last path
* segment and the `#` fragment are read.
*/
fun parseInviteLink(text: String): ParsedInvite? {
val trimmed = text.trim()
val hash = trimmed.indexOf('#')
if (hash <= 0 || hash == trimmed.lastIndex) return null
val naddr = trimmed.substring(0, hash).substringAfterLast('/')
val fragment = decodeInviteFragment(trimmed.substring(hash + 1)) ?: return null
val coordinate = runCatching { Nip19Coordinate.fromBech32(naddr) }.getOrNull() ?: return null
if (coordinate.coordinate().kind().asU16() != ConcordKind.INVITE_BUNDLE.toUShort()) return null
return ParsedInvite(
signerHex = coordinate.coordinate().publicKey().toHex(),
identifier = coordinate.coordinate().identifier(),
naddrRelays = coordinate.relays(),
fragment = fragment,
)
}
/**
* CORD-05 §3's fragment layout:
*
* ```
* [version=4][flags][relays?][token:16]
* ```
*
* With the stock flag set no relay bytes follow. Otherwise a count byte precedes that many entries,
* each a leading byte selecting a dictionary id, a host with `wss://` implied, or a verbatim URL.
*/
fun decodeInviteFragment(fragment: String): InviteFragment? {
val bytes = runCatching { fragmentBase64.decode(fragment) }.getOrNull() ?: return null
// Two header bytes plus the token at minimum.
if (bytes.size < 2 + TOKEN_BYTES) return null
val reader = FragmentReader(bytes)
if (reader.byte() != FRAGMENT_VERSION) return null
val flags = reader.byte() ?: return null
// The stock flag selects the whole dictionary, so nothing extra is carried and the cap on
// explicit entries does not apply to it.
val relays = if (flags and FLAG_STOCK_RELAYS != 0) {
ConcordRelayDictionary.STOCK
} else {
val count = reader.byte() ?: return null
buildList {
repeat(count) {
val lead = reader.byte() ?: return null
val relay = when (lead) {
0 -> reader.slice(reader.byte() ?: return null)?.let { "wss://$it" }
255 -> reader.slice(reader.byte() ?: return null)
else -> ConcordRelayDictionary.STOCK.getOrNull(lead - 1)
} ?: return null
add(relay)
}
}.take(MAX_BOOTSTRAP_RELAYS)
}
// Whatever is left must be exactly the token: a short one silently weakens the link.
val token = bytes.copyOfRange(reader.index, bytes.size)
if (token.size != TOKEN_BYTES) return null
return InviteFragment(
version = FRAGMENT_VERSION,
relays = relays,
tokenHex = token.toHex(),
)
}
/**
* `bundle_key = hkdf(token, "concord/invite-key")` (CORD-05 §2). The token derives exactly this one
* thing decrypting the bundle and nothing else.
*
* Modelled as a `group_key` with an all-zero `id` and no epoch, per CORD-02 A.6, because the spec's
* `nip44_encrypt(bundle_key, )` is the same self-ECDH conversation key every other Concord
* `nip44_encrypt` call uses (CORD-01). Encoding it as a keypair rather than a raw conversation key
* is what lets the existing SDK do the encryption instead of hand-rolling NIP-44.
*/
fun inviteBundleKey(tokenHex: String): GroupKey? =
tokenHex.hexToBytesOrNull()?.let { groupKey(ConcordLabel.INVITE_KEY, it, ByteArray(32)) }
/** Decrypts a `kind 33301` bundle's content into [CommunityInvite], or null on any failure. */
fun decryptInviteBundle(content: String, key: GroupKey): CommunityInvite? {
val plaintext = runCatching { nip44Decrypt(key.secretKey, key.publicKey, content) }.getOrNull()
?: return null
return runCatching { concordJson.decodeFromString<CommunityInvite>(plaintext) }.getOrNull()
}
/** CORD-05 §2: a link is retired by re-posting its coordinate as a `vsk 9` tombstone. */
fun isInviteTombstone(vsk: String?): Boolean = vsk == ConcordVsk.INVITE_TOMBSTONE.toString()
/**
* CORD-05 §1's required checks, in the order the spec gives them. Returns every problem found so
* the preview can explain itself rather than silently refusing.
*
* The first one is the load-bearing one: `community_id == sha256("concord/community" owner
* owner_salt)` is what stops a bundle smuggling a false owner or a fake key for a real Community.
*/
fun CommunityInvite.problems(): List<String> {
val problems = mutableListOf<String>()
val ownerBytes = owner.hex32()
val saltBytes = ownerSalt.hex32()
val rootBytes = communityRoot.hex32()
if (ownerBytes == null) problems += "The invite's owner key is malformed"
if (saltBytes == null) problems += "The invite's owner salt is malformed"
if (rootBytes == null) problems += "The invite's community key is malformed"
if (ownerBytes != null && saltBytes != null) {
if (communityId(ownerBytes, saltBytes).toHex() != communityId.lowercase()) {
problems += "The invite does not prove its community id"
}
}
if (communityId.hex32() == null) problems += "The invite's community id is malformed"
if (controlPk != null && controlPk.hex32() == null) problems += "The invite's control key is malformed"
if (channels.size > MAX_INVITE_CHANNELS) {
problems += "The invite carries ${channels.size} channels (max $MAX_INVITE_CHANNELS)"
}
channels.forEachIndexed { index, channel ->
if (channel.id.hex32() == null) problems += "Channel $index has a malformed id"
if (channel.key.hex32() == null) problems += "Channel $index has a malformed key"
}
return problems
}
/** CORD-05 §1: an expired bundle still previews, but joining refuses. */
fun CommunityInvite.isExpired(nowMs: Long): Boolean = expiresAt != null && expiresAt <= nowMs
/**
* The Community's relay set as the invite names it. [fallback] is the link's bootstrap relays,
* used only when the bundle lists none: the bootstrap set exists to *find* the bundle, and the
* bundle's copy is the join-time snapshot of the real set (CORD-02 §6).
*/
fun CommunityInvite.relaySet(fallback: List<String> = emptyList()): List<String> {
val source = relays.ifEmpty { fallback }
return source.map { it.trim() }.filter { it.isNotEmpty() }.distinct().take(MAX_COMMUNITY_RELAYS)
}
/** Turns a validated bundle into the membership we persist. */
fun CommunityInvite.toMembership(relays: List<String>): Membership = Membership(
communityId = communityId.lowercase(),
owner = owner.lowercase(),
ownerSalt = ownerSalt.lowercase(),
communityRoot = communityRoot.lowercase(),
rootEpoch = rootEpoch,
controlPk = controlPk?.lowercase(),
controlRoot = null,
relays = relays,
name = name,
channels = channels.map { channel ->
StoredChannelKey(
id = channel.id.lowercase(),
key = channel.key.lowercase(),
epoch = channel.epoch,
name = channel.name,
// A Public Channel is one whose key *is* the community_root (CORD-03 §1), which is the
// only reading the bundle supports. The Control fold overrides this for display.
private = !channel.key.equals(communityRoot, ignoreCase = true),
)
},
)
/** Reads the fragment's length-prefixed fields, refusing to run past the end. */
private class FragmentReader(private val bytes: ByteArray) {
var index = 0
private set
fun byte(): Int? = if (index < bytes.size) bytes[index++].toInt() and 0xff else null
fun slice(length: Int): String? {
if (length < 0 || index + length > bytes.size) return null
val value = bytes.decodeToString(index, index + length)
index += length
return value
}
}
@@ -0,0 +1,282 @@
package su.reya.coop.concord
/**
* Frozen constants from the Concord specification.
*
* Concord is defined by the CORD documents (github.com/concord-protocol/concord); the
* numbers, labels and permission bits below are normative and must match byte for byte
* or nothing interoperates. They are collected in this one file so that a spec revision
* is a one-file change and so that no literal kind number ever appears at a call site.
*
* References are to the CORD section that defines each value. See `PLAN.md` Appendix A
* for the same tables with prose.
*/
/**
* Event kinds, either the outer wrap or the inner rumor it carries (CORD-01, CORD-02 §5,
* CORD-02 Appendix B).
*/
object ConcordKind {
/** NIP-59 gift wrap. Concord reuses it but reverses the roles: fixed author, ephemeral `p`. */
const val WRAP = 1059
/** Ephemeral wrap for kinds that must never be stored (typing, voice presence). */
const val EPHEMERAL_WRAP = 21059
/**
* Sealed content for everything that must stay secret: Chat, Guestbook, rekey blobs
* and Direct Invites. CORD-02 §5 makes this choice normative, never stylistic.
*/
const val SEAL = 20013
/**
* Plaintext seal. **Control plane only** (CORD-02 §5) the roster is public by design
* so that membership, and therefore authority, is verifiable by anyone.
*/
const val PLAINTEXT_SEAL = 20014
/** Channel message (NIP-C7 shape). Chat plane. */
const val MESSAGE = 9
/** Threaded reply (NIP-22 shape). Chat plane. */
const val REPLY = 1111
/** Reaction (NIP-25 shape). Chat plane. */
const val REACTION = 7
/** Delete (NIP-09 shape). Chat plane. */
const val DELETE = 5
/** Disappearing-message timer notice (CORD-08 §4). Chat plane. */
const val TIMER_NOTICE = 1740
/** Edit. Chat plane. */
const val EDIT = 3302
/** Rekey blobs (CORD-06). Rekey addresses. */
const val REKEY = 3303
/** Join / Leave. Guestbook plane. */
const val JOIN_LEAVE = 3306
/** Control edition — sub-kinded by [ConcordVsk]. Control plane. */
const val CONTROL_EDITION = 3308
/** Kick. Guestbook plane. */
const val KICK = 3309
/** WebXDC peer signal. Chat plane. */
const val WEBXDC_SIGNAL = 3310
/** Guestbook snapshot, chunked, refounder-signed. Guestbook plane. */
const val GUESTBOOK_SNAPSHOT = 3312
/** Typing indicator. Chat plane, ephemeral. */
const val TYPING = 23311
/** Voice presence (CORD-07). Chat plane, ephemeral. */
const val VOICE_PRESENCE = 23313
/** Direct Invite — giftwrapped straight to an npub. Rides a standard wrap, not a stream. */
const val DIRECT_INVITE = 3313
/** Public invite bundle. Addressable, signed by its per-link keypair at an empty `d`. */
const val INVITE_BUNDLE = 33301
/** Community List — one addressable event per fragment, NIP-44 to self. */
const val COMMUNITY_LIST = 33302
/** Invite List — replaceable, NIP-44 to self. */
const val INVITE_LIST = 13303
}
/** Control edition sub-kinds, the `vsk` tag on [ConcordKind.CONTROL_EDITION] (CORD-02 Appendix A). */
object ConcordVsk {
/** Community metadata. */
const val METADATA = 0
/** Role. */
const val ROLE = 1
/** Channel metadata — the only other edition v1 folds. */
const val CHANNEL = 2
/** Grant. */
const val GRANT = 3
/** Banlist. */
const val BANLIST = 4
/** Reserved for role ordering. */
const val RESERVED_ROLE_ORDER = 5
/** Claimed by the invite bundle's live marker. */
const val INVITE_LIVE = 6
/** Retired (was the v1 owner attestation). */
const val RETIRED_OWNER_ATTESTATION = 7
/** Invite-link registry. */
const val INVITE_REGISTRY = 8
/** Claimed by the invite bundle's revocation tombstone. */
const val INVITE_TOMBSTONE = 9
/** Dissolved tombstone — chainless, exempt from version discipline. */
const val DISSOLVED = 10
/** Pin List. */
const val PIN_LIST = 11
}
/**
* Permission bits (CORD-04 §3). Rank ordering is separate: `position` orders authority and
* **lower is higher**, with the owner at position 0.
*/
object ConcordPermission {
const val MANAGE_ROLES = 1 shl 0
const val MANAGE_CHANNELS = 1 shl 1
const val MANAGE_METADATA = 1 shl 2
const val KICK = 1 shl 3
const val BAN = 1 shl 4
const val MANAGE_MESSAGES = 1 shl 5
const val CREATE_INVITE = 1 shl 6
/** Retired (was `MANAGE_INVITES`). Never grant it. */
const val RETIRED_MANAGE_INVITES = 1 shl 7
const val VIEW_AUDIT_LOG = 1 shl 8
const val MENTION_EVERYONE = 1 shl 9
// 1 shl 10 is reserved.
const val PIN_MESSAGES = 1 shl 11
// 1 shl 12 is reserved.
/**
* Staff = anyone holding a staff bit, plus the owner. Staff are the ones who hold
* `control_root` and can therefore write to the Control plane.
*/
const val STAFF = MANAGE_ROLES or MANAGE_CHANNELS or MANAGE_METADATA or
BAN or CREATE_INVITE or PIN_MESSAGES
}
/**
* HKDF label registry (CORD-02 Appendix A.6). The label is the *first* field of the HKDF
* `info` (see `hkdfInfo`), so these strings are on the wire and must not be edited.
*/
object ConcordLabel {
/** A Channel's group key. `secret` = channel key, or `community_root` for a public channel. */
const val CHANNEL = "concord/channel"
/** Control Plane **read** key. `secret` = `community_root`. */
const val CONTROL = "concord/control"
/** Control Plane signer, held by staff only. `secret` = `control_root`. */
const val CONTROL_SIGNER = "concord/control-signer"
/** Guestbook Plane group key. `secret` = `community_root`. */
const val GUESTBOOK = "concord/guestbook"
/** Dissolution tombstone address. `secret` = `community_id`, id = 32 zero bytes, no epoch. */
const val DISSOLVED = "concord/dissolved"
/** A channel rekey address. `secret` = the prior `community_root`. */
const val REKEY_PSEUDONYM = "concord/rekey-pseudonym"
/** A base (community-wide) rekey address. `secret` = the prior `community_root`. */
const val BASE_REKEY_PSEUDONYM = "concord/base-rekey-pseudonym"
/** A rekey blob locator. `secret` = `rotator_xonly ‖ recipient_xonly`. */
const val RECIPIENT_PSEUDONYM = "concord/recipient-pseudonym"
/** SFU room keypair (CORD-07). */
const val VOICE_SIGNER = "concord/voice-signer"
/** 32-byte call media key (CORD-07). */
const val VOICE_MEDIA = "concord/voice-media"
/** Per-sender frame key (CORD-07). id = `sha256(identity)`, no epoch. */
const val VOICE_SENDER = "concord/voice-sender"
/** A member's Grant coordinate. no epoch. */
const val GRANT = "concord/grant"
/** Banlist coordinate. no epoch. */
const val BANLIST = "concord/banlist"
/** A Channel's Pin List coordinate. no epoch. */
const val PINS = "concord/pins"
/** A creator's invite-link registry coordinate. no epoch. */
const val INVITE_LINKS = "concord/invite-links"
/** Public-invite decrypt key. `secret` = the link's unlock token, no epoch. */
const val INVITE_KEY = "concord/invite-key"
/**
* Prefix for the `community_id` commitment (CORD-02 A.4). Not an HKDF label it is
* hashed directly, with no `0x00` separator and no length prefix.
*/
const val COMMUNITY = "concord/community"
/** Prefix for `prevcommit` (CORD-02 A.8). Also hashed directly, not through HKDF. */
const val EPOCH_KEY_COMMITMENT = "concord/epoch-key-commitment"
/**
* Label for `edition_hash` (CORD-02 A.8).
*
* Deliberately **not** under the `concord/` prefix: the spec pins the reference
* implementation's own `vector` label here. Reproduce it exactly.
*/
const val EDITION_HASH = "vector-community/v1/edition"
}
/** Tag names (CORD-01, CORD-02 §5 and the per-kind sections). */
object ConcordTag {
/** `["ms", "<0..999>"]` — sub-second ordering. True time = `created_at * 1000 + ms`. */
const val MS = "ms"
/** `["p", "<ephemeral pubkey>"]` — the one tag a stream wrap carries (NIP-59 reversed). */
const val P = "p"
/** `["channel", "<channel_id>"]` — MUST also be committed inside the author-signed rumor. */
const val CHANNEL = "channel"
/** `["epoch", "<n>"]` — MUST also be committed inside the author-signed rumor. */
const val EPOCH = "epoch"
/** `["q", "<rumor id>", "", "<author>"]` — NIP-C7 inline quote. */
const val QUOTE = "q"
/** `["vsk", "<n>"]` — Control edition entity type. */
const val VSK = "vsk"
/** `["eid", "<hex32>"]` — stable coordinate of a Control edition's entity. */
const val EID = "eid"
/** `["ev", "<n>"]` — this edition's version, climbing from 1. */
const val EV = "ev"
/** `["ep", "<hash hex>"]` — previous edition hash. Absent on the first edition. */
const val EP = "ep"
/** `["vac", "<grant eid>", "<version>", "<hash>"]` — authority citation. Absent when the owner acts. */
const val VAC = "vac"
/** `["expiration", "<secs>"]` — NIP-40. MUST be on both wrap and rumor, same value. */
const val EXPIRATION = "expiration"
/** `["d", ""]` / `["d", "<index>"]` — addressable coordinate. */
const val D = "d"
/** `["snap", "<id>", "<i>", "<n>"]` — Guestbook snapshot chunk. */
const val SNAP = "snap"
/** `["invite", "<creator hex>", "<label>"]` — optional invite attribution on a join. */
const val INVITE = "invite"
/** `["k", "3313"]` — the one deliberate outer-tag exception on a Direct Invite wrap. */
const val K = "k"
}
@@ -0,0 +1,584 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.decodeFromString
import rust.nostr.sdk.AckPolicy
import rust.nostr.sdk.Event
import rust.nostr.sdk.Filter
import rust.nostr.sdk.Kind
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.AppStorage
import su.reya.coop.nostr.Nostr
import kotlin.concurrent.Volatile
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
/**
* Concord's read path: which planes we hold keys for, what we fold out of them, and how an invite
* turns into membership.
*
* ## Why this lives on [Nostr]
*
* The client has exactly **one** notification pump. `client.notifications()` is called once, inside
* [Nostr.handleNotifications], and a second consumer would silently split the stream so Concord
* routes through that pump rather than subscribing to its own (see [isPlaneAddress] and [onInboxRumor]).
*
* ## The two phases
*
* [restore] is local and fast: it reads memberships from storage and folds whatever the Control
* plane has already cached, so routing is meaningful before anything hits the network. [sync] is
* the network half: connect the Community's relays and subscribe to its plane addresses. The pump
* calls them in that order, so the first event that arrives already knows where it belongs.
*/
class ConcordManager(private val nostr: Nostr) {
/**
* Concord's plane pubkeys, keyed by hex. Every incoming `kind 1059` is routed by author: a
* Concord wrap is signed by a plane's *derived* stream key and can never be read by the NIP-17
* path, which assumes an ephemeral author and a `p`-tagged recipient.
*
* Replaced wholesale rather than mutated, and read from the notification pump's thread while a
* worker coroutine rewrites it, so the reference is volatile.
*/
@Volatile
private var planes: Map<String, Plane> = emptyMap()
/** Latest Control fold per community id. Replaced wholesale; see [planes]. */
@Volatile
private var folds: Map<String, ControlFold> = emptyMap()
/**
* Unread badges by Channel id. Replaced wholesale; see [planes]. In memory only, mirroring
* `Room.unreadCount` in the chat layer, so a restart begins with every badge cleared.
*/
@Volatile
private var unread: Map<String, Int> = emptyMap()
/**
* Bumped whenever a plane rumor is cached or a message is sent, so a screen showing a Channel
* has something to re-query on. A counter rather than a set of changed scopes, because two
* messages to the same Channel must both be observable.
*/
private val _revision = MutableStateFlow(0L)
val revision: StateFlow<Long> = _revision.asStateFlow()
private val _memberships = MutableStateFlow<List<Membership>>(emptyList())
val memberships: StateFlow<List<Membership>> = _memberships.asStateFlow()
private val _communities = MutableStateFlow<List<CommunityState>>(emptyList())
val communities: StateFlow<List<CommunityState>> = _communities.asStateFlow()
/** Direct Invites that arrived giftwrapped to us and have not been accepted or dismissed. */
private val _directInvites = MutableStateFlow<List<CommunityInvite>>(emptyList())
val directInvites: StateFlow<List<CommunityInvite>> = _directInvites.asStateFlow()
private var store: ConcordStore? = null
/**
* Hands over the encrypted storage [Nostr] cannot reach on its own.
*
* [Nostr] is a Context-free singleton, so the Android layer passes [AppStorage] in through
* [Nostr.init]. Until this is called nothing can be persisted or restored, and the manager
* simply routes nothing.
*/
internal fun attach(storage: AppStorage) {
store = ConcordStore(storage, nostr)
}
// -----------------------------------------------------------------------------------------
// Routing
// -----------------------------------------------------------------------------------------
/** True when a wrap's author is one of our planes' stream keys. */
fun isPlaneAddress(authorHex: String): Boolean = planes.containsKey(authorHex)
/**
* Handles one stream wrap from a plane we hold a key for.
*
* [PlaneKey.unwrap] runs every check a reader must make and returns null on any failure, so a
* forged, unverifiable, mis-bound or undecryptable wrap is dropped here without ever being
* rendered. What survives is stored under its logical scope and, for Control, re-folded.
*/
suspend fun handlePlaneEvent(event: Event) {
val plane = planes[event.author().toHex()] ?: return
val rumor = plane.key.unwrap(event) ?: return
val store = store ?: return
store.cacheRumor(plane.scopeIdHex, event.id().toHex(), rumor)
_revision.update { it + 1 }
// A message from someone else is the only thing a badge counts. Our own wraps come back
// through the same subscription and so does a re-fetch, so the check is on the rumor's
// author rather than on the arrival.
if (plane.role == PlaneRole.Channel) countUnread(plane.scopeIdHex, rumor)
// Control carries consensus state, so every edition changes what we can read: a new
// Channel means a new plane address, and therefore a new subscription.
if (plane.role == PlaneRole.Control) refreshControl(plane.communityIdHex)
}
/**
* Intercepts rumors that arrive through the ordinary NIP-17 giftwrap pipeline and belong to
* Concord instead.
*
* Returns true when Concord consumed the rumor. A Direct Invite (kind `3313`) rides a *standard*
* NIP-59 wrap, so it unwraps fine but it is a key handoff, not a message, and letting it
* through would have the chat layer build a room out of it. Returning true here keeps all
* Concord kind knowledge inside this package and leaves the chat code untouched.
*/
fun onInboxRumor(rumor: UnsignedEvent): Boolean {
if (rumor.kind().asU16() != ConcordKind.DIRECT_INVITE.toUShort()) return false
val invite = runCatching { concordJson.decodeFromString<CommunityInvite>(rumor.content()) }.getOrNull()
if (invite == null) {
println("Concord: a direct invite arrived but its bundle could not be read")
return true
}
if (invite.problems().isNotEmpty()) {
println("Concord: refusing a direct invite that does not prove its community id")
return true
}
_directInvites.update { current ->
if (current.any { it.communityId.equals(invite.communityId, ignoreCase = true) }) current
else current + invite
}
return true
}
// -----------------------------------------------------------------------------------------
// Lifecycle
// -----------------------------------------------------------------------------------------
/** Local half of startup: load memberships, fold the cached Control plane, index the planes. */
suspend fun restore() {
val store = store ?: return
val memberships = store.loadMemberships()
_memberships.value = memberships
// Fold what is already on disk so Channels and metadata render immediately. Live editions
// arrive later through handlePlaneEvent and re-fold.
folds = memberships.associate { membership ->
membership.communityId to ConcordControl.fold(
editions = store.cachedRumors(membership.communityId)
.mapNotNull { ConcordControl.editionOf(it) },
communityIdHex = membership.communityId,
)
}
refreshPlanes()
}
/** Network half of startup: connect every Community's relays and subscribe to its planes. */
suspend fun sync() {
for (membership in _memberships.value) {
try {
subscribeCommunity(membership)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: could not subscribe to ${membership.communityId}: ${e.message}")
}
}
}
// -----------------------------------------------------------------------------------------
// Invites
// -----------------------------------------------------------------------------------------
/**
* Fetches and decrypts the bundle behind a public invite link, so the UI can show what joining
* would mean. Nothing is joined, nothing is subscribed and no presence is announced here
* (CORD-05 §1: a bundle is passive until the user accepts).
*/
suspend fun previewInvite(link: String): InvitePreview {
val parsed = parseInviteLink(link) ?: throw IllegalArgumentException("That is not a Concord invite link")
val bundleKey = inviteBundleKey(parsed.fragment.tokenHex)
?: throw IllegalArgumentException("The invite link's token is malformed")
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
// The fragment's bootstrap relays only have to *find* the bundle; the bundle then carries
// the Community's real relay set (CORD-05 §3).
val relays = (parsed.naddrRelays.map { it.toString() } + parsed.fragment.relays)
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
.distinct()
if (relays.isEmpty()) throw IllegalStateException("The invite link names no relay to fetch from")
relays.forEach { relay ->
client.addRelay(relay)
client.connectRelay(relay)
}
val filter = Filter()
.kind(Kind(ConcordKind.INVITE_BUNDLE.toUShort()))
.author(PublicKey.fromBytes(parsed.signerHex.hexToBytes()))
.identifier(parsed.identifier)
val bundle = client
.fetchEvents(ReqTarget.manual(relays.associateWith { listOf(filter) }), timeout = 8.seconds)
.toVec()
.firstOrNull()
?: throw IllegalStateException("No invite bundle was found at that link")
if (isInviteTombstone(bundle.tagValue(ConcordTag.VSK))) {
throw IllegalStateException("This invite link has been revoked")
}
val invite = decryptInviteBundle(bundle.content(), bundleKey)
?: throw IllegalStateException("The invite bundle could not be opened")
return preview(invite, link, parsed.fragment.relays)
}
/** The same preview for a Direct Invite, which arrived with no link to fetch. */
fun previewDirectInvite(invite: CommunityInvite): InvitePreview = preview(invite, link = null, fallbackRelays = emptyList())
/** Accepts an invite: persist the keys, connect, subscribe, and announce the join. */
suspend fun join(preview: InvitePreview): CommunityState {
preview.problems.firstOrNull()?.let { throw IllegalArgumentException(it) }
if (preview.expired) throw IllegalArgumentException("That invite has expired")
val store = store ?: throw IllegalStateException("Concord storage is not ready")
val membership = preview.invite.toMembership(preview.relays)
val updated = _memberships.value.filterNot { it.communityId == membership.communityId } + membership
store.saveMemberships(updated)
_memberships.value = updated
refreshPlanes()
subscribeCommunity(membership)
// CORD-02 §5: a Join is each member's own word, published to the Guestbook. There is no
// Guestbook fold in v1 — the Control plane is what drives the UI.
if (!preview.alreadyJoined) publishJoin(membership, preview.invite)
_directInvites.update { invites ->
invites.filterNot { it.communityId.equals(membership.communityId, ignoreCase = true) }
}
return _communities.value.firstOrNull { it.membership.communityId == membership.communityId }
?: CommunityState(membership, null, emptyList())
}
// -----------------------------------------------------------------------------------------
// Reading
// -----------------------------------------------------------------------------------------
/**
* A Channel's messages, oldest first. Reads the local cache; the live subscription is what
* keeps it current.
*/
suspend fun channelMessages(channelIdHex: String): List<ConcordMessage> =
store?.cachedRumors(channelIdHex).orEmpty()
.mapNotNull { it.toConcordMessage() }
.sortedBy { it.timestampMs }
/** A Channel's unread badge. See [ConcordChannel.unreadCount] for what it does and does not survive. */
fun unreadCount(channelIdHex: String): Int = unread[channelIdHex] ?: 0
/** Clears a Channel's badge — the Channel screen calls this once its messages are on display. */
fun markChannelRead(channelIdHex: String) {
if ((unread[channelIdHex] ?: 0) == 0) return
unread = unread - channelIdHex
publishUnread(channelIdHex, 0)
}
// -----------------------------------------------------------------------------------------
// Writing
// -----------------------------------------------------------------------------------------
/**
* Sends a message to a Channel (CORD-03 §3).
*
* The send *is* [PlaneKey.rumor] plus [PlaneKey.wrap], with nothing in between: the rumor carries
* the `channel`/`epoch` binding stamped from the Channel's own key, and the wrap is signed by that
* key's stream half, so a message cannot be built for a coordinate it will not verify at.
*
* The message is cached locally *before* it is published, so it is visible even if every relay is
* unreachable and so the subscription's echo of the wrap lands on the same `d` slot instead of
* duplicating it.
*
* Returns the message as a reader will see it. Throws when the Channel is hidden behind a key we
* were never granted a Private Channel is listable without being writable.
*/
suspend fun sendChannelMessage(channelIdHex: String, content: String): ConcordMessage {
val client = nostr.client ?: throw IllegalStateException("Nostr client is not ready")
val plane = planes.values.firstOrNull {
it.role == PlaneRole.Channel && it.scopeIdHex.equals(channelIdHex, ignoreCase = true)
} ?: throw IllegalArgumentException("That Channel is not one we hold a key for")
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val rumor = plane.key.rumor(
author = author,
kind = ConcordKind.MESSAGE.toUShort(),
content = content,
createdAt = Clock.System.now(),
)
val wrap = plane.key.wrap(rumor, nostr.signer)
store?.cacheRumor(plane.scopeIdHex, wrap.id().toHex(), rumor)
_revision.update { it + 1 }
// Always the Community's own relays, never the app's defaults (see [subscribeCommunity]).
val relays = _memberships.value
.firstOrNull { it.communityId == plane.communityIdHex }
?.relays
.orEmpty()
.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
if (relays.isEmpty()) throw IllegalStateException("That Community names no relay to publish to")
client.sendEvent(event = wrap, target = SendEventTarget.to(relays), ackPolicy = AckPolicy.none())
.failed.forEach { (relay, reason) -> println("Concord: $relay refused a message: $reason") }
return rumor.toConcordMessage()
?: throw IllegalStateException("Concord: could not read back the message just sent")
}
/** Records a badge change and re-publishes [communities] so a badge drawn from it moves. */
private fun publishUnread(channelIdHex: String, count: Int) {
_communities.update { states ->
if (states.none { state -> state.channels.any { it.idHex == channelIdHex } }) return@update states
states.map { state ->
state.copy(
channels = state.channels.map {
if (it.idHex == channelIdHex) it.copy(unreadCount = count) else it
}
)
}
}
}
/** Counts one incoming message against its Channel, ignoring our own and anything but a message. */
private fun countUnread(channelIdHex: String, rumor: UnsignedEvent) {
if (rumor.kind().asU16() != ConcordKind.MESSAGE.toUShort()) return
if (rumor.author() == nostr.signer.publicKeyFlow.value) return
val count = (unread[channelIdHex] ?: 0) + 1
unread = unread + (channelIdHex to count)
publishUnread(channelIdHex, count)
}
// -----------------------------------------------------------------------------------------
// Planes
// -----------------------------------------------------------------------------------------
/**
* Rebuilds the plane index and the Community read model from the memberships and folds we hold.
*
* Returns the communities whose set of plane addresses changed, because those are exactly the
* ones whose subscription is now stale a Control edition that adds a Channel adds an address.
*/
private fun refreshPlanes(): Set<String> {
val previous = planes
val next = mutableMapOf<String, Plane>()
val states = mutableListOf<CommunityState>()
for (membership in _memberships.value) {
val communityId = membership.communityId.hex32()
val root = membership.communityRoot.hex32()
val fold = folds[membership.communityId]
val granted = membership.channels.associateBy { it.id }
if (communityId != null && root != null) {
// Control is write-restricted: we hold the read key plus the writers' pubkey, which
// is all reading takes (CORD-01, CORD-02 §5). A bundle with no `control_pk` is a
// legacy, pre-split Community, whose plane was addressed by the `concord/control`
// derivation itself; v1 does not read those, so such a Community shows no metadata.
// CORD-02 §5 requires that legacy reading and the first base rotation upgrades it.
membership.controlPk?.let { controlPkHex ->
if (controlPkHex.hex32() != null) {
val key = controlPlaneKey(root, communityId, membership.rootEpoch, controlPkHex)
next[key.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Control,
key = key,
scopeIdHex = membership.communityId,
)
}
}
val guestbook = guestbookPlaneKey(root, communityId, membership.rootEpoch)
next[guestbook.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Guestbook,
key = guestbook,
scopeIdHex = membership.communityId,
)
}
val channelIds = (granted.keys + fold?.channels?.keys.orEmpty()).distinct()
val channels = channelIds.mapNotNull { channelIdHex ->
val meta = fold?.channels?.get(channelIdHex)
if (meta?.deleted == true) return@mapNotNull null
val stored = granted[channelIdHex]
val channelId = channelIdHex.hex32()
// A Public Channel's key *is* the community_root, so one we were never granted is
// still derivable; a Private one is not (CORD-03 §1).
val secret = stored?.key?.hex32()
?: if (meta?.isPrivate != true) root else null
val epoch = stored?.epoch ?: membership.rootEpoch
var plane: PlaneKey? = null
if (channelId != null && secret != null) {
plane = channelPlaneKey(secret, channelId, epoch)
next[plane.streamPublicKeyHex] = Plane(
communityIdHex = membership.communityId,
role = PlaneRole.Channel,
key = plane,
scopeIdHex = channelIdHex,
)
}
ConcordChannel(
idHex = channelIdHex,
name = meta?.name ?: stored?.name ?: channelIdHex.take(8),
private = meta?.isPrivate ?: stored?.private ?: false,
epoch = epoch,
hasKey = plane != null,
// Carried across the re-index rather than recomputed: a Control edition must not
// silently clear every badge, the same way ChatRepository preserves its counters.
unreadCount = unread[channelIdHex] ?: 0,
)
}
states += CommunityState(
membership = membership,
meta = fold?.meta,
channels = channels.sortedBy { it.name.lowercase() },
)
}
planes = next
_communities.value = states
return _memberships.value
.filter { membership ->
previous.planeKeys(membership.communityId) != next.planeKeys(membership.communityId)
}
.map { it.communityId }
.toSet()
}
/** Re-folds one Community's Control plane from the cache, then re-indexes if planes shifted. */
private suspend fun refreshControl(communityIdHex: String) {
val store = store ?: return
val editions = store.cachedRumors(communityIdHex).mapNotNull { ConcordControl.editionOf(it) }
folds = folds + (communityIdHex to ConcordControl.fold(editions, communityIdHex))
if (communityIdHex in refreshPlanes()) {
_memberships.value.firstOrNull { it.communityId == communityIdHex }?.let { subscribeCommunity(it) }
}
}
/**
* Subscribes to one Community: connect its relays, then ask for every plane's address.
*
* `channel` and `epoch` are multi-letter tags, and Nostr filters only accept single-letter ones,
* so plane addresses are the finest granularity available. That is the right level anyway one
* key per plane and the `channel`/`epoch` binding is a strict check after decryption.
*/
private suspend fun subscribeCommunity(membership: Membership) {
val client = nostr.client ?: return
val id = subscriptionId(membership.communityId)
client.unsubscribe(id)
val relays = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }.distinct()
if (relays.isEmpty()) return
// Always the Community's own relays, never the app's defaults: Concord reverses NIP-59
// (fixed author, ephemeral `p`), so a relay enforcing the optional `p`-tag guard drops
// these wraps, and the bootstrap set is tuned for NIP-17.
relays.forEach { relay ->
client.addRelay(relay)
client.connectRelay(relay)
}
val filters = planes.values
.filter { it.communityIdHex == membership.communityId }
.mapNotNull { plane -> plane.key.streamPublicKeyHex.hex32() }
.distinct()
.map { Filter().kind(Kind(ConcordKind.WRAP.toUShort())).author(PublicKey.fromBytes(it)) }
if (filters.isEmpty()) return
client.subscribe(target = ReqTarget.manual(relays.associateWith { filters }), id = id)
}
/** Publishes a still-fetchable Guestbook Join (CORD-02 §5) — each member's own word. */
private suspend fun publishJoin(membership: Membership, invite: CommunityInvite?) {
val client = nostr.client ?: return
val communityId = membership.communityId.hex32() ?: return
val root = membership.communityRoot.hex32() ?: return
val author = nostr.signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val plane = guestbookPlaneKey(root, communityId, membership.rootEpoch)
// CORD-05 §1: an accepting joiner echoes the invite's creator and label, which is what
// makes per-link usage counters possible at all.
val extraTags = invite?.creatorNpub?.let { creator ->
listOf(Tag.custom(ConcordTag.INVITE, listOf(creator, invite.label.orEmpty())))
}.orEmpty()
val rumor = plane.rumor(
author = author,
kind = ConcordKind.JOIN_LEAVE.toUShort(),
content = "join",
createdAt = Clock.System.now(),
extraTags = extraTags,
)
val wrap = plane.wrap(rumor, nostr.signer)
val targets = membership.relays.mapNotNull { runCatching { RelayUrl.parse(it) }.getOrNull() }
client.sendEvent(event = wrap, target = SendEventTarget.to(targets), ackPolicy = AckPolicy.none())
}
private fun subscriptionId(communityIdHex: String): String = "$SUBSCRIPTION_PREFIX$communityIdHex"
private fun preview(
invite: CommunityInvite,
link: String?,
fallbackRelays: List<String>,
): InvitePreview {
val relays = invite.relaySet(fallbackRelays)
val problems = invite.problems() + if (relays.isEmpty()) listOf("The invite names no relays") else emptyList()
return InvitePreview(
link = link,
invite = invite,
relays = relays,
problems = problems,
expired = invite.isExpired(Clock.System.now().toEpochMilliseconds()),
alreadyJoined = _memberships.value.any { it.communityId.equals(invite.communityId, ignoreCase = true) },
)
}
private companion object {
const val SUBSCRIPTION_PREFIX = "concord:"
}
}
/** What a plane is for — the Community-wide Control and Guestbook planes, or one Channel. */
private enum class PlaneRole { Control, Guestbook, Channel }
/**
* One plane address we hold: the key to decrypt it, the role that says how to fold it, and the
* [scopeIdHex] it caches under.
*/
private class Plane(
val communityIdHex: String,
val role: PlaneRole,
val key: PlaneKey,
val scopeIdHex: String,
)
private fun Map<String, Plane>.planeKeys(communityIdHex: String): Set<String> =
filterValues { it.communityIdHex == communityIdHex }.keys
/** The first content value of a tag, or null. Used for tags read without their companion fields. */
private fun Event.tagValue(kind: String): String? =
tags().toVec().firstOrNull { it.kind() == kind }?.content()
@@ -0,0 +1,259 @@
package su.reya.coop.concord
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.UnsignedEvent
import kotlin.time.Instant
/**
* Concord's data model: what we persist about a Community, what an invite carries, and what the
* Control fold projects.
*
* Types that travel on the wire ([CommunityInvite], [CommunityMeta], [ChannelMeta], [ConcordIcon])
* keep the spec's snake_case field names because those keys are normative. Types that are ours
* alone ([Membership], [StoredChannelKey]) keep Kotlin naming.
*
* Concord reserves every top-level field it does not define, and CORD-02 §6 requires editors to
* round-trip fields they do not understand. v1 only ever *reads* these documents, so the models
* below carry just the fields v1 uses and rely on `ignoreUnknownKeys`; anything aimed at writing
* them back must preserve what it did not parse.
*/
internal val concordJson = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// ---------------------------------------------------------------------------------------------
// Persisted
// ---------------------------------------------------------------------------------------------
/**
* A Community this device has joined, as stored locally. Holding these keys *is* membership
* (CORD-02 §2), so the whole blob lives in [su.reya.coop.AppStorage]'s encrypted store.
*
* There is no owner recovery by design: [communityId] commits to [owner], so losing the owner key
* cannot be repaired by anyone, including us. Nothing in the UI should suggest otherwise.
*/
@Serializable
data class Membership(
/** Hex `community_id`. Never appears on the wire; every coordinate derives from it one-way. */
val communityId: String,
/** Owner x-only pubkey hex, the other half of the `community_id` commitment. */
val owner: String,
/** 32-byte hex salt; not secret, travels in invites so anyone can recompute the commitment. */
val ownerSalt: String,
/** 32-byte hex `community_root`. The base access key — never leaves the encrypted store. */
val communityRoot: String,
/** Epoch the root was issued at (CORD-02 §3). */
val rootEpoch: ULong,
/** Control Plane signer pubkey (CORD-02 §5), used to *address* the plane. Trusted on trust. */
val controlPk: String? = null,
/** Staff-only write key. v1 never writes to the Control Plane, so this stays null. */
val controlRoot: String? = null,
/** The Community's relays — always from the invite, never the app's defaults (see PLAN.md 13.2). */
val relays: List<String> = emptyList(),
/** Last known name, cached so a community renders before its Control fold lands. */
val name: String? = null,
/** Channels this invite actually granted a key for. */
val channels: List<StoredChannelKey> = emptyList(),
)
/**
* A Channel key handed out by an invite. Public Channels derive from `community_root` and so carry
* it here, which is why [private] can only be a hint the Control fold is the authority.
*/
@Serializable
data class StoredChannelKey(
val id: String,
val key: String,
val epoch: ULong,
val name: String? = null,
val private: Boolean = false,
)
// ---------------------------------------------------------------------------------------------
// Invite bundle (CORD-05 §1)
// ---------------------------------------------------------------------------------------------
/**
* The `CommunityInvite` bundle: the same document whether it arrives inside a public link's
* encrypted relay-side event or giftwrapped straight to an npub as a Direct Invite (CORD-05 §6).
*
* The `community_id` self-certifies the owner, so a bundle cannot smuggle a false owner onto a real
* Community. [controlPk] is the one field taken on trust: it derives from a secret the joiner will
* never hold, so nothing in the bundle can prove it. Build nothing security-relevant on it.
*/
@Serializable
data class CommunityInvite(
@SerialName("community_id") val communityId: String = "",
val owner: String = "",
@SerialName("owner_salt") val ownerSalt: String = "",
@SerialName("community_root") val communityRoot: String = "",
@SerialName("root_epoch") val rootEpoch: ULong = 0u,
@SerialName("control_pk") val controlPk: String? = null,
val channels: List<InviteChannel> = emptyList(),
val relays: List<String> = emptyList(),
val name: String? = null,
val icon: ConcordIcon? = null,
/** Unix **milliseconds** — see [expiryToEpochSeconds]. Optional. */
@SerialName("expires_at") val expiresAt: Long? = null,
@SerialName("creator_npub") val creatorNpub: String? = null,
val label: String? = null,
)
/** One granted Channel inside a bundle. `key` is `community_root` for a Public one (CORD-03 §1). */
@Serializable
data class InviteChannel(
val id: String = "",
val key: String = "",
val epoch: ULong = 0u,
val name: String? = null,
)
/**
* A pointer to an encrypted blob icon, banner (CORD-02 §6). The media server holds ciphertext
* only; a member fetches, decrypts and verifies [hash]. v1 never fetches these.
*/
@Serializable
data class ConcordIcon(
val url: String? = null,
val key: String? = null,
val nonce: String? = null,
val hash: String? = null,
)
// ---------------------------------------------------------------------------------------------
// Control fold
// ---------------------------------------------------------------------------------------------
/** Community metadata — the `vsk 0` entity's content (CORD-02 §6). */
@Serializable
data class CommunityMeta(
val name: String? = null,
val description: String? = null,
/** The Community's own relay set. This is the authority; the invite's copy is a snapshot. */
val relays: List<String> = emptyList(),
val icon: ConcordIcon? = null,
val banner: ConcordIcon? = null,
/** Disappearing-messages timer in seconds (CORD-08). Read only; v1 does not enforce it. */
@SerialName("message_expiration") val messageExpiration: Long? = null,
)
/** Channel metadata — the `vsk 2` entity's content (CORD-03 §2). */
@Serializable
data class ChannelMeta(
val name: String? = null,
@SerialName("private") val isPrivate: Boolean = false,
/** Terminal: the id is never reused and clients drop the Channel (CORD-03 §2). */
val deleted: Boolean = false,
)
/**
* One `kind 3308` Control edition, parsed from its rumor (CORD-04 §1, CORD-02 Appendix B).
*
* The tags are the edition machinery `vsk` names the entity type, `eid` its stable coordinate,
* `ev` this version, `ep` the hash of the previous edition. [content] is the entity's new state as
* a JSON string, held verbatim because [editionHash] hashes those bytes and never a re-serialization.
*/
data class ControlEdition(
val vsk: Int,
val eidHex: String,
val version: ULong,
val prevHashHex: String?,
val content: String,
val actorHex: String,
val rumorIdHex: String,
val createdAt: Instant,
)
/** The projected Control state v1 uses: the Community's metadata and its Channel list. */
data class ControlFold(
val meta: CommunityMeta?,
val channels: Map<String, ChannelMeta>,
)
// ---------------------------------------------------------------------------------------------
// Read surface
// ---------------------------------------------------------------------------------------------
/** A Channel as the UI sees it: its identity, and whether we actually hold a key to read it. */
data class ConcordChannel(
val idHex: String,
val name: String,
val private: Boolean,
val epoch: ULong,
/** False for a Private Channel we were never granted — listable, but not readable. */
val hasKey: Boolean,
/**
* Messages from others since this Channel was last opened. In memory only, exactly like
* `Room.unreadCount`: it survives a Control re-fold, but not a restart.
*/
val unreadCount: Int = 0,
)
/** A Community with its Control fold applied. */
data class CommunityState(
val membership: Membership,
/** Null until a `vsk 0` edition has been folded; the invite's name fills in before that. */
val meta: CommunityMeta?,
val channels: List<ConcordChannel>,
)
/** A Chat-plane message, already unwrapped and signature-checked. */
data class ConcordMessage(
val idHex: String,
val author: String,
val content: String,
val createdAt: Instant,
/** CORD-02 §4: `created_at * 1000 + ms`. The only ordering basis the protocol uses. */
val timestampMs: Long,
)
/** A redeemed-in-part invite, ready for the UI to preview before anything joins (CORD-05 §1). */
data class InvitePreview(
/** Null for a Direct Invite, which arrives with no link. */
val link: String?,
val invite: CommunityInvite,
/** Resolved relay set: the bundle's, falling back to the link's bootstrap relays. */
val relays: List<String>,
/** Non-empty means the bundle is refused — these are the reasons, in plain English. */
val problems: List<String>,
val expired: Boolean,
val alreadyJoined: Boolean,
) {
val joinable: Boolean get() = problems.isEmpty() && !expired
}
// ---------------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------------
/**
* CORD-05 §1: the bundle's `expires_at` is unix **milliseconds**, the Invite List's is unix
* **seconds**, and a NIP-40 `expiration` tag is **seconds**. Three spellings of one instant, so
* every conversion lives here. Returns null when the bundle carries no expiry at all.
*/
fun CommunityInvite.expiryToEpochSeconds(): Long? = expiresAt?.let { it / 1000 }
/** CORD-02 §4: true time is `created_at * 1000 + ms`; an absent or out-of-range `ms` reads as 0. */
internal fun UnsignedEvent.concordTimestampMs(): Long {
val ms = tags().toVec()
.firstOrNull { it.kind() == ConcordTag.MS }
?.content()
?.toIntOrNull()
?.takeIf { it in 0..999 }
return createdAt().asSecs().toLong() * 1000 + (ms ?: 0)
}
/** Projects a Chat-plane rumor into a [ConcordMessage], or null when it is not a message. */
internal fun UnsignedEvent.toConcordMessage(): ConcordMessage? {
if (kind().asU16() != ConcordKind.MESSAGE.toUShort()) return null
return ConcordMessage(
idHex = id()?.toHex().orEmpty(),
author = author().toHex(),
content = content(),
createdAt = Instant.fromEpochSeconds(createdAt().asSecs().toLong()),
timestampMs = concordTimestampMs(),
)
}
@@ -0,0 +1,308 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.Nip44Version
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.Timestamp
import rust.nostr.sdk.UnsignedEvent
import rust.nostr.sdk.nip44Decrypt
import rust.nostr.sdk.nip44Encrypt
import kotlin.time.Instant
/**
* The CORD-01 wire stack: `wrap seal rumor`.
*
* A stream is a shared key and a stream of gift wraps signed with it. Everything a plane sends
* is three nested layers:
*
* ```
* wrap kind 1059, signed by the stream key, one ephemeral `p` tag
* nip44(conv_key) of
* seal kind 20013 (encrypted) or 20014 (plaintext), signed by the real author
* nip44(conv_key) of | byte-verbatim
* rumor unsigned, its authority is the seal's signature around it
* ```
*
* Both encrypted layers use the *same* conversation key that is double encryption under one
* key, not two, and it is what makes the wrap readable by anyone holding the plane key.
*
* Concord reverses NIP-59: the author is fixed (the stream key) and the `p` tag is ephemeral,
* which is why the app's normal NIP-59 path in `MessageManager.extractRumor` cannot read these
* and why routing happens by author before that code is reached.
*/
/**
* Which of CORD-01's two seal forms a plane uses; CORD-02 §5 makes this a fixed property of the
* plane, "never a per-message choice". Because the plane owns it, [wrap] cannot pick the wrong
* one and [unwrap] can reject a seal of the other form instead of quietly accommodating it.
*/
enum class SealForm(private val kindValue: Int) {
/**
* Chat, Guestbook, rekey. The rumor is NIP-44-encrypted inside the already-encrypted wrap,
* so no relay honest or malicious can retain and display the rumor as a public event.
*/
Encrypted(ConcordKind.SEAL),
/**
* Control plane only. The seal's content is the rumor's serialized JSON **byte-verbatim**,
* because a signature over ciphertext is bound to the key that encrypted it and would break
* if re-wrapped under another key across an epoch change.
*/
Plaintext(ConcordKind.PLAINTEXT_SEAL);
val kind: UShort get() = kindValue.toUShort()
}
/**
* CORD-03 §3: what a Chat-plane rumor must commit so a member cannot re-wrap another's message
* into a different Channel or replay it across an epoch.
*
* The tags live *inside* the author-signed rumor, so the author's signature covers them; the
* reader checks them strict-equal against the coordinate whose key opened the wrap.
*/
data class ChatBinding(val channelIdHex: String, val epoch: ULong)
/**
* One plane's keys and, for a Chat plane, the coordinate its rumors are bound to.
*
* [read] decrypts: it is the conversation key for the wrap and for an encrypted seal. [stream]
* is the address that signs wraps. They are the same key on a normal stream, and differ only on
* a write-restricted one (CORD-01, used by the Control Plane in CORD-02 §5) where a reader
* holds the read key plus the writers' *pubkey*, enough to verify a wrap but not to mint one.
*
* Deliberately not a `data class`: there is no value equality worth having, and the default
* `toString` keeps key material out of logs.
*/
class PlaneKey(
val read: GroupKey,
val stream: GroupKey?,
/** The x-only hex of the key every wrap on this plane must be signed by. */
val streamPublicKeyHex: String,
/** CORD-02 §5: the one seal form this plane may use, on both the write and the read side. */
val form: SealForm,
val chat: ChatBinding?,
)
/**
* A Channel's Chat plane (CORD-03 §1). Public channels pass `community_root` as the secret,
* Private ones their own independent `channel_key`; the `channel_id` in the derivation is what
* gives each Channel a distinct address either way.
*/
fun channelPlaneKey(channelSecret: ByteArray, channelId: ByteArray, epoch: ULong): PlaneKey {
val key = groupKey(ConcordLabel.CHANNEL, channelSecret, channelId, epoch)
return PlaneKey(
read = key,
stream = key,
streamPublicKeyHex = key.publicKey.toHex(),
form = SealForm.Encrypted,
chat = ChatBinding(channelId.toHex(), epoch),
)
}
/** The community-wide Guestbook plane (CORD-02 §5), where joins, leaves and kicks are recorded. */
fun guestbookPlaneKey(communityRoot: ByteArray, communityId: ByteArray, epoch: ULong): PlaneKey {
val key = groupKey(ConcordLabel.GUESTBOOK, communityRoot, communityId, epoch)
return PlaneKey(
read = key,
stream = key,
streamPublicKeyHex = key.publicKey.toHex(),
form = SealForm.Encrypted,
chat = null,
)
}
/**
* The Control plane's **read** key (CORD-02 §5). Its wraps are signed by the staff-held
* `control-signer` key instead, so this plane is read-only and [streamPublicKeyHex] is
* whatever the invite claimed nothing in an invite can prove it, so build nothing
* security-relevant on it beyond the subscription address.
*/
fun controlPlaneKey(
communityRoot: ByteArray,
communityId: ByteArray,
epoch: ULong,
streamPublicKeyHex: String,
): PlaneKey {
val key = groupKey(ConcordLabel.CONTROL, communityRoot, communityId, epoch)
return PlaneKey(
read = key,
stream = null,
streamPublicKeyHex = streamPublicKeyHex,
form = SealForm.Plaintext,
chat = null,
)
}
/**
* Builds an unsigned rumor for this plane (CORD-01).
*
* The `channel`/`epoch` binding tags are stamped from the plane itself, and therefore from the
* very key that will encrypt the wrap, so a rumor can never be built whose coordinate does not
* match the key it travels under. `ms` rides every rumor (CORD-02 A.5) because `created_at` is
* never tweaked true time is `created_at * 1000 + ms`.
*
* A rumor is never signed and never a standalone artifact: its authority is the seal's
* signature around it.
*/
fun PlaneKey.rumor(
author: PublicKey,
kind: UShort,
content: String,
createdAt: Instant,
extraTags: List<Tag> = emptyList(),
): UnsignedEvent {
val tags = buildList {
chat?.let {
add(Tag.custom(ConcordTag.CHANNEL, listOf(it.channelIdHex)))
add(Tag.custom(ConcordTag.EPOCH, listOf(it.epoch.toString())))
}
add(Tag.custom(ConcordTag.MS, listOf(msOf(createdAt).toString())))
addAll(extraTags)
}
return EventBuilder(Kind(kind), content)
.tags(tags)
.customCreatedAt(Timestamp.fromSecs(createdAt.epochSeconds.toULong()))
.finalizeUnsigned(author)
.ensureId()
}
/**
* Wraps [rumor] into a publishable stream event.
*
* [signer] signs the *seal*, so it must be the real author's signer the user's own key, which
* also means a NIP-46 bunker works here. The wrap is signed by the plane's stream key instead,
* and the seal form comes from the plane itself (CORD-02 §5).
*
* Both the seal and the wrap take the rumor's `created_at`, never a fresh one, so the three
* layers agree and pagination by wrap timestamp lines up with message ordering.
*/
suspend fun PlaneKey.wrap(rumor: UnsignedEvent, signer: AsyncNostrSigner): Event {
val stream = stream ?: error("Concord: this plane is read-only and cannot wrap")
val createdAt = rumor.createdAt()
val rumorJson = rumor.asJson()
val seal = try {
EventBuilder(
Kind(form.kind),
// CORD-01: byte-verbatim for a plaintext seal, so a re-wrap can carry the exact
// signed bytes forward instead of re-serializing them.
if (form == SealForm.Encrypted) nip44Seal(read, rumorJson) else rumorJson,
)
.customCreatedAt(createdAt)
.finalizeAsync(signer)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Concord: failed to seal rumor: ${e.message}", e)
}
// The receiver drops a rumor whose author differs from its seal's, so publishing a mismatch
// would produce a message nobody can read. Fail here instead, where the cause is visible.
check(seal.author() == rumor.author()) {
"Concord: seal author ${seal.author().toHex()} does not match rumor author ${rumor.author().toHex()}"
}
return try {
// The ephemeral `p` is discarded: it only breaks linkage between a plane's wraps. Only
// its pubkey is kept, and `Tag.publicKey` has already serialized it, so destroying the
// keypair right away is safe. v1 has no giftwrap deletion, which is the one thing
// CORD-01 §Deletions would want the secret for.
val ephemeral = Keys.generate().use { Tag.publicKey(it.publicKey()) }
Keys(stream.secretKey).use { keys ->
EventBuilder(Kind(concordKind(ConcordKind.WRAP)), nip44Seal(read, seal.asJson()))
.tags(listOf(ephemeral))
.customCreatedAt(createdAt)
.finalize(keys)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Concord: failed to wrap seal: ${e.message}", e)
}
}
/**
* Unwraps a stream event and enforces every check a reader must make, returning null when any
* of them fails.
*
* Null means **drop**, never retry: a wrong key, a forged or unverifiable signature, an
* impersonation attempt, a decompression/serialization failure, or a `channel`/`epoch`
* mismatch (CORD-03 §3). The checks are all here so a caller cannot skip one, and nothing is
* rendered before they pass.
*/
fun PlaneKey.unwrap(event: Event): UnsignedEvent? {
if (event.kind().asU16() != ConcordKind.WRAP.toUShort()) return null
// The wrap is signed by the stream key. Verifying this is what makes CORD-01's
// write-restricted split real: a read-key holder can verify a wrap but cannot mint one.
if (event.author().toHex() != streamPublicKeyHex) return null
if (!event.verify()) return null
val seal = runCatching {
Event.fromJson(nip44Decrypt(read.secretKey, read.publicKey, event.content()))
}.getOrNull() ?: return null
if (!seal.verify()) return null
// CORD-02 §5 makes the seal form a fixed property of the plane, so a seal of the other form
// is a discipline violation, not a variant to accommodate: only the matching pair is accepted.
val sealKind = seal.kind().asU16()
val rumorJson = when {
sealKind == SealForm.Encrypted.kind && form == SealForm.Encrypted -> runCatching {
nip44Decrypt(read.secretKey, read.publicKey, seal.content())
}.getOrNull() ?: return null
// Plaintext seal: the rumor's bytes are already the content, never re-parsed as an event.
sealKind == SealForm.Plaintext.kind && form == SealForm.Plaintext -> seal.content()
else -> return null
}
val rumor = runCatching { UnsignedEvent.fromJson(rumorJson).ensureId() }.getOrNull() ?: return null
// NIP-59's impersonation check: the seal proves who wrote the rumor inside it.
if (rumor.author() != seal.author()) return null
if (!bindsToThisPlane(rumor)) return null
return rumor
}
/**
* CORD-03 §3, strict-equal and fail-closed: on a Chat plane both tags must be present and match
* this plane's coordinate, so neither a re-wrap into another Channel nor a cross-epoch replay
* survives. The community-wide Guestbook and Control planes split no sub-context, so the spec
* binds nothing there and this is a no-op.
*/
private fun PlaneKey.bindsToThisPlane(rumor: UnsignedEvent): Boolean {
val expected = chat ?: return true
val tags = rumor.tags().toVec()
val channel = tags.firstOrNull { it.kind() == ConcordTag.CHANNEL }?.content()
val epoch = tags.firstOrNull { it.kind() == ConcordTag.EPOCH }?.content()
return channel == expected.channelIdHex && epoch == expected.epoch.toString()
}
/**
* CORD-02 A.5: true time is `created_at * 1000 + ms`, and a reader drops a rumor whose `ms`
* falls outside `0..999`. `mod` rather than `%` so a pre-epoch timestamp cannot produce a
* negative value.
*/
private fun msOf(createdAt: Instant): Int = createdAt.toEpochMilliseconds().mod(1000L).toInt()
/**
* NIP-44's plaintext cap, enforced by the publisher (CORD-01 §Encoding).
*
* Libraries are lenient and a lenient publisher mints events a strict reader cannot decrypt, so
* this fails loudly at build time instead of producing an undecryptable message.
*/
private const val MAX_PLAINTEXT_BYTES = 65_535
private fun nip44Seal(key: GroupKey, plaintext: String): String {
val size = plaintext.encodeToByteArray().size
require(size <= MAX_PLAINTEXT_BYTES) {
"Concord: NIP-44 plaintext is $size bytes, over the $MAX_PLAINTEXT_BYTES cap"
}
return nip44Encrypt(key.secretKey, key.publicKey, plaintext, Nip44Version.V2)
}
/** `Kind` for a frozen [ConcordKind] number; the SDK's constructor wants a `UShort`. */
private fun concordKind(kind: Int): UShort = kind.toUShort()
@@ -0,0 +1,113 @@
package su.reya.coop.concord
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
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.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.AppStorage
import su.reya.coop.nostr.Nostr
/**
* Concord's local persistence, in the two places the rest of the app already keeps things.
*
* **Secrets [AppStorage]'s encrypted store.** A Community's keys *are* membership (CORD-02 §2),
* so they go through `setSecret`, which is backed by Android Keystore AES-GCM. They must never go
* through plaintext storage, and they never touch LMDB.
*
* **Community state LMDB, as index events.** Decrypted plane rumors are cached the same way
* [su.reya.coop.nostr.MessageManager] caches DM rumors, so history survives a restart and the
* Control fold has something to fold before the network answers.
*/
class ConcordStore(private val storage: AppStorage, private val nostr: Nostr) {
suspend fun loadMemberships(): List<Membership> {
val raw = try {
storage.getSecret(MEMBERSHIPS_KEY)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: could not read memberships: ${e.message}")
return emptyList()
} ?: return emptyList()
return try {
concordJson.decodeFromString<List<Membership>>(raw)
} catch (e: Exception) {
println("Concord: stored memberships are unreadable: ${e.message}")
emptyList()
}
}
suspend fun saveMemberships(memberships: List<Membership>) {
try {
storage.setSecret(MEMBERSHIPS_KEY, concordJson.encodeToString(memberships))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Concord: could not store memberships: ${e.message}", e)
}
}
/**
* Caches one decrypted plane rumor under its logical scope a Channel id for Chat, the
* community id for Control and Guestbook.
*
* The `d` tag is not optional. [KindStandard.APPLICATION_SPECIFIC_DATA] is an *addressable*
* kind, so LMDB keeps one event per `(kind, pubkey, d)` coordinate: without a unique `d` every
* message would replace the one before it and history would vanish silently. The wrap id is
* that unique value, exactly as [su.reya.coop.nostr.MessageManager.setCachedRumor] uses it
* and it doubles as the dedupe key when a wrap arrives from several relays.
*/
suspend fun cacheRumor(scopeIdHex: String, wrapIdHex: String, rumor: UnsignedEvent) {
try {
val event = EventBuilder(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA), rumor.asJson())
.tags(
listOf(
Tag.identifier(wrapIdHex),
Tag.custom(INDEX_SCOPE_TAG, listOf(scopeIdHex)),
Tag.custom(INDEX_KIND_TAG, listOf(rumor.kind().asU16().toString())),
)
)
.finalizeAsync(Keys.generate())
nostr.client?.database()?.saveEvent(event)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: failed to cache rumor: ${e.message}")
}
}
/** Every cached rumor for one scope, oldest first. */
suspend fun cachedRumors(scopeIdHex: String): List<UnsignedEvent> {
val events = try {
val filter = Filter()
.kind(Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA))
.reference(scopeIdHex)
nostr.client?.database()?.query(filter)?.toVec() ?: return emptyList()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Concord: failed to read cached rumors: ${e.message}")
return emptyList()
}
return events
.mapNotNull { runCatching { UnsignedEvent.fromJson(it.content()).ensureId() }.getOrNull() }
.sortedBy { it.createdAt().asSecs() }
}
private companion object {
const val MEMBERSHIPS_KEY = "concord_memberships"
/** Single-letter index tags; `r` is what `Filter.reference` queries. */
const val INDEX_SCOPE_TAG = "r"
const val INDEX_KIND_TAG = "k"
}
}
@@ -0,0 +1,44 @@
package su.reya.coop.nostr
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
/**
* Platform interface for NIP-55 external signer communication.
* Implemented on Android; no-op/null on other platforms.
*/
interface ExternalSignerHandler {
fun isAvailable(): Boolean
fun setPackageName(packageName: String)
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
}
@Serializable
data class SignerPermission(
val type: String,
val kind: Int? = null,
)
object SignerPermissions {
fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
fun toJson(permissions: List<SignerPermission>): String {
return Json.encodeToString(permissions)
}
}
data class ExternalSignerResult(
val pubkey: PublicKey,
val packageName: String,
)
@@ -0,0 +1,40 @@
package su.reya.coop.nostr
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
class ExternalSignerProxy(
private val handler: ExternalSignerHandler,
private val currentUser: PublicKey,
) : AsyncNostrSigner {
override suspend fun getPublicKeyAsync(): PublicKey {
return currentUser
}
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
return Event.fromJson(signedJson)
}
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
return handler.nip04Encrypt(content, publicKey)
?: throw Exception("NIP-04 encrypt rejected")
}
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
return handler.nip04Decrypt(encryptedContent, publicKey)
?: throw Exception("NIP-04 decrypt rejected")
}
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
return handler.nip44Encrypt(content, publicKey, currentUser)
?: throw Exception("NIP-44 encrypt rejected")
}
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
return handler.nip44Decrypt(payload, publicKey, currentUser)
?: throw Exception("NIP-44 decrypt rejected")
}
}
@@ -0,0 +1,438 @@
package su.reya.coop.nostr
import kotlinx.coroutines.CancellationException
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
private val _messageSyncState = MutableStateFlow(MessageSyncState())
val messageSyncState = _messageSyncState.asStateFlow()
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} catch (e: Exception) {
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: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
}
}
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
try {
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val kValue = if (isReaction) "reaction" else "dm"
// 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", kValue))
)
// 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: CancellationException) {
throw e
} catch (e: Exception) {
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 rumors (DMs and Reactions)
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction"))
val events = client?.database()?.query(filter)?.toVec() ?: return null
// Collect rooms
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
val lastDmTimestampMap: MutableMap<Long, ULong> = mutableMapOf()
events
.map { UnsignedEvent.fromJson(it.content()) }
.filter { it.tags().publicKeys().isNotEmpty() }
.forEach { rumor ->
val id = rumor.roomId()
val isFromMe = rumor.author() == userPubkey
val isReaction = rumor.kind().asStd() == KindStandard.REACTION
val existing = roomsMap[id]
val createdAt = rumor.createdAt()
if (existing == null) {
val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id)
// If the first event we see is a reaction, don't use it as lastMessage
roomsMap[id] = if (isReaction) {
room.copy(lastMessage = null)
} else {
lastDmTimestampMap[id] = createdAt.asSecs()
room
}
if (isFromMe) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
} else {
// Update the overall room timestamp (for sorting) if this event is newer
if (createdAt.asSecs() > existing.createdAt.asSecs()) {
roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt)
}
// Update the last message content if this is a DM and it's newer than the last DM we've seen
if (!isReaction) {
val lastDmTs = lastDmTimestampMap[id] ?: 0uL
if (createdAt.asSecs() >= lastDmTs) {
lastDmTimestampMap[id] = createdAt.asSecs()
roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content())
}
}
// If any event is from the user, mark the room as Ongoing
if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) {
roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing)
}
}
}
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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 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: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to send message: ${e.message}", e)
}
}
suspend fun sendReaction(
to: Set<PublicKey>,
targetEventId: EventId,
reaction: String,
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
) {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
val tags = mutableListOf<Tag>()
tags.add(Tag.event(targetEventId))
// Add public key tags for each recipient (including me) to ensure roomId consistency
to.forEach { pubkey ->
tags.add(Tag.publicKey(pubkey))
}
for (receiver in setOf(currentUser) + to) {
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.REACTION), reaction)
.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 rumor IDs
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
rumorMap[id] = output.id
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to send reaction: ${e.message}", e)
}
}
}
@@ -0,0 +1,283 @@
package su.reya.coop.nostr
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
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 kotlinx.coroutines.withTimeoutOrNull
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 su.reya.coop.AppStorage
import su.reya.coop.concord.ConcordManager
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
object NostrManager {
val instance = Nostr()
}
class Nostr(
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
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)
val concord = ConcordManager(this)
private val isInitialized = MutableStateFlow(false)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
fun emitNewEvent(event: UnsignedEvent) {
_newEvents.tryEmit(event)
}
suspend fun init(
dbPath: String,
storage: AppStorage,
logLevel: LogLevel = LogLevel.WARN,
) {
try {
if (isInitialized.value) return
// Concord's keys live in the encrypted store, which only the Android layer can build.
// Handing it over here keeps it out of the Context-free singleton's constructor.
concord.attach(storage)
// 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() {
withTimeoutOrNull(30.seconds) { isInitialized.first { it } }
?: throw IllegalStateException("Nostr initialization timed out")
}
suspend fun connectBootstrapRelays() {
relays.connectBootstrapRelays()
}
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>(1024)
val concordQueue = Channel<Event>(1024)
var processedCount = 0
var eoseReceived = false
try {
concord.restore()
} catch (e: Exception) {
println("Failed to restore Concord state: ${e.message}")
}
launch(defaultDispatcher) { concord.sync() }
launch(defaultDispatcher) {
for (event in concordQueue) {
try {
concord.handlePlaneEvent(event)
} catch (e: Exception) {
println("Failed to handle Concord plane event: ${e.message}")
}
}
}
launch(defaultDispatcher) {
for (event in giftWrapQueue) {
val rumor = messages.extractRumor(event)
processedCount++
if (rumor != null && !concord.onInboxRumor(rumor)) {
val isSelfMessage = rumor.author() == signer.publicKeyFlow.value
val isNew = rumor.createdAt().asSecs() >= now.asSecs()
if (isNew && !isSelfMessage) {
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 -> {
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.updateSyncState { it.copy(isSyncing = true) }
messages.getUserMessages(msgRelayList = event)
}
}
KindStandard.GIFT_WRAP -> {
if (concord.isPlaneAddress(event.author().toHex())) {
concordQueue.send(event)
} else {
giftWrapQueue.send(event)
}
}
else -> {}
}
}
is RelayMessageEnum.EndOfStoredEvents -> {
if (message.subscriptionId == "gift-wraps") {
eoseReceived = true
if (giftWrapQueue.isEmpty) {
messages.updateSyncState { it.copy(isSyncing = false) }
}
}
}
else -> {
/* Ignore other message types */
}
}
}
is ClientNotification.Shutdown -> {
break
}
else -> {
/* Ignore other message types */
}
}
}
}
}
@@ -0,0 +1,395 @@
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.channels.BufferOverflow
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.coroutines.cancellation.CancellationException
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 httpClient by lazy { HttpClient() }
private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates =
MutableSharedFlow<List<PublicKey>>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
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: CancellationException) {
throw e
} 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 = RelayManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
client?.sync(filter, relays)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Failed to sync mutual contacts: ${e.message}")
}
}
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 {
try {
val currentUser =
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
// Get the latest metadata event
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
// Build a new metadata based on old records
val newMetadata = Metadata.fromRecord(
record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
)
// Send the new metadata event
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
return newMetadata
} catch (e: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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>>()
RelayManager.BOOTSTRAP_RELAYS.forEach { relay ->
target[RelayUrl.parse(relay)] = listOf(filter)
}
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
} catch (e: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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>>()
RelayManager.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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} 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: CancellationException) {
throw e
} catch (e: Exception) {
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
}
}
}
@@ -0,0 +1,199 @@
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) {
companion object {
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
"wss://user.kindpag.es",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
private val client: Client? get() = nostr.client
private val signer: UniversalSigner get() = nostr.signer
suspend fun connectBootstrapRelays() {
BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse(url))
}
INDEXER_RELAY.forEach { url ->
client?.addRelay(
url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip()
)
}
// Connect to all bootstrap relays
client?.connect()
}
suspend fun reconnect() {
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() {
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 getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
try {
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
val filter = Filter().kind(kind).author(publicKey).limit(1u)
val events = client?.database()?.query(filter)
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
} catch (e: Exception) {
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
}
}
suspend fun setRelayList(relays: Map<RelayUrl, RelayMetadata?>) {
try {
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none(),
)
} catch (e: Exception) {
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
}
}
suspend fun 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)
}
}
}
@@ -1,32 +1,42 @@
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 {
signer = newSigner
currentUser = newSigner.getPublicKeyAsync()
suspend fun switch(newSigner: AsyncNostrSigner) {
val pubkey = withTimeoutOrNull(20.seconds) { newSigner.getPublicKeyAsync() }
?: throw IllegalStateException("Failed to get public key from signer")
mutex.withLock {
signer = newSigner
_publicKeyFlow.value = pubkey
}
}
override suspend fun getPublicKeyAsync(): PublicKey? {
@@ -52,4 +62,4 @@ class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
return get().nip44DecryptAsync(publicKey, payload)
}
}
}
@@ -0,0 +1,588 @@
package su.reya.coop.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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 rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.Timestamp
import su.reya.coop.AppStorage
import su.reya.coop.Profile
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.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class AccountState(
val signerRequired: Boolean? = null,
val isNotificationBannerDismissed: Boolean = false,
val isImporting: Boolean = false,
val isRelayListEmpty: Boolean = false,
val contactList: Set<PublicKey> = emptySet(),
val userRelayList: Map<RelayUrl, RelayMetadata?> = emptyMap(),
val userMsgRelayList: List<RelayUrl> = emptyList(),
)
class AccountRepository(
private val nostr: Nostr,
private val storage: AppStorage,
private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope,
private val externalSignerHandler: ExternalSignerHandler? = null,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
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(AccountState())
val state: StateFlow<AccountState> = _state.asStateFlow()
private val _isUpdatingProfile = MutableStateFlow(false)
val isUpdatingProfile: StateFlow<Boolean> = _isUpdatingProfile.asStateFlow()
@OptIn(ExperimentalCoroutinesApi::class)
val currentUserProfile: StateFlow<Profile?> = nostr.signer.publicKeyFlow
.flatMapLatest { if (it != null) currentUserProfileFlow(it) else flowOf(null) }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), null)
init {
checkNotificationBannerDismissedStatus()
login()
observeSignerState()
observeContactList()
}
private fun observeSignerState() {
scope.launch {
nostr.signer.publicKeyFlow
.filterNotNull()
.distinctUntilChanged()
.collectLatest { pubkey ->
// Refresh metadata
nostr.profiles.getUserMetadata()
// Verify messaging relays exist (wait up to 10s for initial fetch)
val relays = withTimeoutOrNull(10.seconds) {
var list = nostr.relays.getMsgRelays(pubkey)
while (list.isEmpty()) {
delay(500.milliseconds)
list = nostr.relays.getMsgRelays(pubkey)
}
list
} ?: emptyList()
// Automatically update the warning state
_state.update { it.copy(isRelayListEmpty = relays.isEmpty()) }
}
}
}
private fun checkNotificationBannerDismissedStatus() {
scope.launch {
val dismissed = storage.get(KEY_BANNER_DISMISSED) == "true"
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
}
}
private fun login() {
scope.launch {
try {
val secret = withTimeoutOrNull(5.seconds) {
storage.getSecret(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 = {}) {
scope.launch {
try {
nostr.signer.switch(Keys.generate())
nostr.prune()
} catch (e: Exception) {
showError("Logout encountered an error: ${e.message}")
} finally {
storage.clear(KEY_USER_SIGNER)
storage.clear(KEY_BANNER_DISMISSED)
onLogout()
_state.update { it.copy(signerRequired = true) }
}
}
}
fun dismissNotificationBanner() {
scope.launch {
storage.set(KEY_BANNER_DISMISSED, "true")
_state.update { it.copy(isNotificationBannerDismissed = true) }
}
}
private suspend fun getOrInitAppKeys(): Keys = withContext(defaultDispatcher) {
val secret = storage.getSecret(KEY_APP_KEYS)
if (secret != null) return@withContext Keys.parse(secret)
val keys = Keys.generate()
storage.setSecret(KEY_APP_KEYS, keys.secretKey().toBech32())
keys
}
private suspend fun createSigner(
secret: String,
password: String? = null
): Pair<AsyncNostrSigner, String?> = withContext(defaultDispatcher) {
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 decrypted = enc.decrypt(password)
val keys = Keys(decrypted)
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("Not available")
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")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
fun importIdentity(secret: String, password: String? = null) {
scope.launch {
_state.update { it.copy(isImporting = true) }
try {
val (signer, decryptedSecret) = createSigner(secret, password)
nostr.setSigner(signer)
storage.setSecret(KEY_USER_SIGNER, decryptedSecret ?: secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Import failed: ${e.message}")
_state.update { it.copy(isImporting = false) }
}
}
}
fun connectExternalSigner() {
scope.launch {
_state.update { it.copy(isImporting = true) }
try {
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)
val uri = "nip55://${result.packageName}/${result.pubkey.toHex()}"
nostr.setSigner(signer)
storage.setSecret(KEY_USER_SIGNER, uri)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("External signer connection failed: ${e.message}")
_state.update { it.copy(isImporting = false) }
}
}
}
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
scope.launch {
_state.update { it.copy(isImporting = true) }
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(keys, it, contentType ?: "image/jpeg")
}
nostr.profiles.createIdentity(
keys = keys,
name = name,
bio = bio,
picture = avatarUrl
)
storage.setSecret(KEY_USER_SIGNER, secret)
_state.update { it.copy(signerRequired = false, isImporting = false) }
} catch (e: Exception) {
showError("Identity creation failed: ${e.message}")
_state.update { it.copy(isImporting = false) }
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun currentUserProfileFlow(pubkey: PublicKey) = merge(
flow {
nostr.waitUntilInitialized()
val cached = nostr.profiles.getAllCacheMetadata()[pubkey]
if (cached != null) emit(Profile(pubkey, cached))
nostr.profiles.fetchMetadataBatch(listOf(pubkey))
},
nostr.profiles.metadataUpdates
.filter { (p, _) -> p == pubkey }
.map { (p, m) -> Profile(p, m) }
)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
scope.launch {
_isUpdatingProfile.value = true
try {
val avatarUrl = picture?.let {
mediaRepository.blossomUpload(
nostr.signer.get(),
it,
contentType ?: "image/jpeg"
)
}
nostr.profiles.updateProfile(name, bio, avatarUrl)
_isUpdatingProfile.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
_isUpdatingProfile.value = false
}
}
}
private fun observeContactList() {
scope.launch {
nostr.waitUntilInitialized()
nostr.profiles.contactListUpdates.collect { contacts ->
_state.update { it.copy(contactList = contacts.toSet()) }
}
}
}
fun resetInternalState() {
_state.update { it.copy(contactList = emptySet(), isRelayListEmpty = false) }
}
fun addContact(address: String) {
scope.launch {
val pubkey = try {
if (address.contains("@")) {
nostr.profiles.searchByAddress(address)
} else {
PublicKey.parse(address)
}
} catch (e: Exception) {
showError("Invalid contact address: ${e.message}")
return@launch
}
if (pubkey in _state.value.contactList) return@launch
try {
val updated = _state.value.contactList + pubkey
nostr.profiles.setContactList(updated.toList())
_state.update { it.copy(contactList = it.contactList + pubkey) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeContact(publicKey: PublicKey) {
scope.launch {
if (publicKey !in _state.value.contactList) return@launch
try {
val updated = _state.value.contactList - publicKey
nostr.profiles.setContactList(updated.toList())
_state.update { it.copy(contactList = it.contactList - publicKey) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByAddress(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.searchByNostr(query))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptyList())
}
}
}
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyActivity(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(null)
}
}
}
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.verifyContact(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(false)
}
}
}
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) {
scope.launch {
try {
onResult(nostr.profiles.mutualContacts(pubkey))
} catch (e: Exception) {
showError("Error: ${e.message}")
onResult(emptySet())
}
}
}
fun dismissRelayWarning() {
_state.update { it.copy(isRelayListEmpty = false) }
}
fun refetchMsgRelays() {
scope.launch {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@launch
val relays = nostr.relays.fetchMsgRelays(currentUser)
if (relays.isNotEmpty()) dismissRelayWarning()
}
}
fun useDefaultMsgRelayList() {
scope.launch {
try {
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
nostr.relays.setMsgRelays(defaultRelays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserRelayList() {
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
val relayList = nostr.relays.getRelayList(user)
_state.update { it.copy(userRelayList = relayList) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun currentUserRelayListInternal(): 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()
}
}
fun addInboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.WRITE
nostr.relays.setRelayList(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun addOutboxRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays[relayUrl] = RelayMetadata.READ
nostr.relays.setRelayList(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = currentUserRelayListInternal().toMutableMap()
relays.remove(relayUrl)
nostr.relays.setRelayList(relays)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadCurrentUserMsgRelayList() {
scope.launch {
try {
val user = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
val msgRelays = nostr.relays.getMsgRelays(user)
_state.update { it.copy(userMsgRelayList = msgRelays) }
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun userLatestMsgRelayList(): 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()
}
}
fun addMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = userLatestMsgRelayList().toMutableSet()
relays.add(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun removeMsgRelay(relay: String) {
scope.launch {
try {
val relayUrl = RelayUrl.parse(relay)
val relays = userLatestMsgRelayList().toMutableSet()
relays.remove(relayUrl)
nostr.relays.setMsgRelays(relays.toList())
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
}
@@ -0,0 +1,315 @@
package su.reya.coop.repository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Room
import su.reya.coop.RoomKind
import su.reya.coop.nostr.Nostr
import su.reya.coop.roomId
import su.reya.coop.viewmodel.ErrorHost
import su.reya.coop.viewmodel.createErrorHost
data class ChatState(
val rooms: Map<Long, Room> = emptyMap(),
val isPartialProcessedGiftWrap: Boolean = false,
)
class ChatRepository(
private val nostr: Nostr,
private val mediaRepository: MediaRepository,
private val settingsRepository: SettingsRepository,
private val scope: CoroutineScope,
private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val _state = MutableStateFlow(ChatState())
val state = _state.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(
replay = 0,
extraBufferCapacity = 100,
onBufferOverflow = BufferOverflow.SUSPEND
)
val newEvents = _newEvents.asSharedFlow()
val chatRooms = state
.map { it.rooms.values.sortedByDescending { it.createdAt.asSecs() } }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), emptyList())
val isSyncing = nostr.messages.messageSyncState
.map { it.isSyncing }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
val isPartialProcessedGiftWrap = state
.map { it.isPartialProcessedGiftWrap }
.stateIn(scope, SharingStarted.WhileSubscribed(5000), false)
init {
scope.launch(defaultDispatcher) {
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 || !syncState.isSyncing) {
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
}
// Refresh UI every 100 messages OR when sync is fully done
if (syncState.processedCount % 100 == 0 || !syncState.isSyncing) {
refreshChatRooms()
}
}
}
// Observe new messages
launch {
nostr.newEvents.collect { event ->
updateRoomState(event)
}
}
}
}
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[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.id to room)) }
return room.id
} catch (e: Exception) {
throw IllegalArgumentException("Failed to create room: ${e.message}")
}
}
fun getChatRoom(id: Long): Room? {
return _state.value.rooms[id]
}
fun markAsRead(roomId: Long) {
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
val room = rooms[roomId]
if (room != null && room.unreadCount > 0) {
rooms[roomId] = room.copy(unreadCount = 0)
currentState.copy(rooms = rooms)
} else {
currentState
}
}
}
fun refreshChatRooms() {
scope.launch(defaultDispatcher) {
try {
val dbRooms = nostr.messages.getChatRooms() ?: emptySet()
_state.update { currentState ->
val newMap = currentState.rooms.toMutableMap()
dbRooms.forEach { dbRoom ->
val existing = newMap[dbRoom.id]
// Only update if the database version is newer or equal
if (existing == null || dbRoom.createdAt.asSecs() >= existing.createdAt.asSecs()) {
// Preserve Ongoing kind and unreadCount status if already marked as such in memory
val mergedKind =
if (existing?.kind == RoomKind.Ongoing) RoomKind.Ongoing else dbRoom.kind
val mergedUnreadCount = existing?.unreadCount ?: 0
newMap[dbRoom.id] = dbRoom.copy(
kind = mergedKind,
unreadCount = mergedUnreadCount
)
}
}
currentState.copy(rooms = newMap)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun loadChatRoomMessages(roomId: Long, onResult: (List<UnsignedEvent>) -> Unit) {
scope.launch {
try {
onResult(nostr.messages.getChatRoomMessages(roomId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun chatRoomConnect(roomId: Long) {
scope.launch(defaultDispatcher) {
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")
return
}
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendMessage(
to = room.members,
content = message,
subject = room.subject,
replies = replies,
onRumorCreated = {
scope.launch(defaultDispatcher) {
updateRoomState(it, roomId)
}
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
fun sendFileMessage(
roomId: Long,
file: ByteArray?,
contentType: String? = "image/jpeg",
replies: List<EventId> = emptyList()
) {
if (file == null) return
scope.launch {
try {
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
if (uri != null) sendMessage(roomId, uri, replies)
} catch (e: Exception) {
showError("File upload failed: ${e.message}")
}
}
}
fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) {
scope.launch(defaultDispatcher) {
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.messages.sendReaction(
to = room.members,
targetEventId = targetEventId,
reaction = reaction,
onRumorCreated = {
scope.launch(defaultDispatcher) {
updateRoomState(it, roomId)
}
},
)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
}
private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) {
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
val isReaction = event.kind().asStd() == KindStandard.REACTION
_state.update { currentState ->
val rooms = currentState.rooms.toMutableMap()
val existingRoom = rooms[roomId]
val isFromMe = event.author() == currentUser
val newKind =
if (isFromMe) RoomKind.Ongoing else (existingRoom?.kind ?: RoomKind.Request)
if (existingRoom == null) {
// New room discovery
val newRoom = Room.new(event, currentUser, roomId).copy(
kind = newKind,
unreadCount = if (isFromMe || isReaction) 0 else 1,
lastMessage = if (isReaction) null else event.content()
)
rooms[newRoom.id] = newRoom
} else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) {
// Update timestamp for any newer event (DM or Reaction)
// But only update preview if it's a DM
rooms[roomId] = existingRoom.copy(
lastMessage = if (isReaction) existingRoom.lastMessage else event.content(),
createdAt = event.createdAt(),
kind = newKind,
unreadCount = if (isFromMe || isReaction) existingRoom.unreadCount else existingRoom.unreadCount + 1
)
} else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) {
// Even if it's an older message or reaction, if it's from me, the room is ongoing
rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing)
} else {
// Don't update the room list state for older messages or reactions that don't change preview
return@update currentState
}
currentState.copy(rooms = rooms)
}
// Notify subscribers about the new event (for the active chat screen)
_newEvents.tryEmit(event)
}
fun resetInternalState() {
_state.update {
it.copy(
rooms = emptyMap(),
isPartialProcessedGiftWrap = false,
)
}
}
}
@@ -0,0 +1,41 @@
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.coroutines.CancellationException
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import su.reya.coop.blossom.BlossomClient
class MediaRepository(
private val settingsRepository: SettingsRepository
) {
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 url = settingsRepository.settings.value.blossomServer ?: "https://blossom.band"
val blossom = BlossomClient(url, httpClient)
val descriptor = blossom.upload(file = file, contentType = contentType, signer = signer)
descriptor?.url
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
println("Upload failed: ${e.message}")
null
}
}
}
@@ -0,0 +1,61 @@
package su.reya.coop.repository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import su.reya.coop.AppStorage
import su.reya.coop.Settings
class SettingsRepository(
private val storage: AppStorage,
private val scope: CoroutineScope
) {
companion object {
private const val KEY_SETTINGS = "app_settings"
}
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val _settings = MutableStateFlow(Settings())
val settings: StateFlow<Settings> = _settings.asStateFlow()
init {
scope.launch {
_settings.value = load()
}
}
fun update(transform: (Settings) -> Settings) {
scope.launch {
val newSettings = transform(_settings.value)
_settings.value = newSettings
save(newSettings)
}
}
private suspend fun save(settings: Settings) {
val jsonString = json.encodeToString(settings)
storage.set(KEY_SETTINGS, jsonString)
}
private suspend fun load(): Settings {
val jsonString = storage.get(KEY_SETTINGS)
return if (jsonString != null) {
try {
json.decodeFromString<Settings>(jsonString)
} catch (_: Exception) {
Settings()
}
} else {
Settings()
}
}
}
@@ -1,8 +0,0 @@
package su.reya.coop.storage
interface SecretStorage {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}
@@ -0,0 +1,66 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Timestamp
import su.reya.coop.Profile
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.AccountState
class AccountViewModel(
private val repository: AccountRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<AccountState> = repository.state
val isUpdatingProfile: StateFlow<Boolean> = repository.isUpdatingProfile
val currentUserProfile: StateFlow<Profile?> = repository.currentUserProfile
fun logout(onLogout: () -> Unit = {}) = repository.logout(onLogout)
fun dismissNotificationBanner() = repository.dismissNotificationBanner()
fun isExternalSignerAvailable() = repository.isExternalSignerAvailable()
fun importIdentity(secret: String, password: String? = null) =
repository.importIdentity(secret, password)
fun connectExternalSigner() = repository.connectExternalSigner()
fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) = repository.createIdentity(name, bio, picture, contentType)
fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) = repository.updateProfile(name, bio, picture, contentType)
fun resetInternalState() = repository.resetInternalState()
fun addContact(address: String) = repository.addContact(address)
fun removeContact(publicKey: PublicKey) = repository.removeContact(publicKey)
fun searchByAddress(query: String, onResult: (PublicKey?) -> Unit) =
repository.searchByAddress(query, onResult)
fun searchByNostr(query: String, onResult: (List<PublicKey>) -> Unit) =
repository.searchByNostr(query, onResult)
fun verifyActivity(pubkey: PublicKey, onResult: (Timestamp?) -> Unit) =
repository.verifyActivity(pubkey, onResult)
fun verifyContact(pubkey: PublicKey, onResult: (Boolean) -> Unit) =
repository.verifyContact(pubkey, onResult)
fun mutualContacts(pubkey: PublicKey, onResult: (Set<PublicKey>) -> Unit) =
repository.mutualContacts(pubkey, onResult)
fun dismissRelayWarning() = repository.dismissRelayWarning()
fun refetchMsgRelays() = repository.refetchMsgRelays()
fun useDefaultMsgRelayList() = repository.useDefaultMsgRelayList()
fun loadCurrentUserRelayList() = repository.loadCurrentUserRelayList()
fun addInboxRelay(relay: String) = repository.addInboxRelay(relay)
fun addOutboxRelay(relay: String) = repository.addOutboxRelay(relay)
fun loadCurrentUserMsgRelayList() = repository.loadCurrentUserMsgRelayList()
fun addMsgRelay(relay: String) = repository.addMsgRelay(relay)
fun removeMsgRelay(relay: String) = repository.removeMsgRelay(relay)
}
@@ -0,0 +1,81 @@
package su.reya.coop.viewmodel
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import rust.nostr.sdk.EventId
import rust.nostr.sdk.UnsignedEvent
import su.reya.coop.Profile
import su.reya.coop.Room
import su.reya.coop.repository.AccountRepository
import su.reya.coop.repository.ChatRepository
import su.reya.coop.roomId
class ChatScreenViewModel(
val id: Long,
screening: Boolean,
accountRepository: AccountRepository,
private val chatRepository: ChatRepository,
) : ViewModel(), ErrorHost by chatRepository {
val currentUser: StateFlow<Profile?> = accountRepository.currentUserProfile
val chatRooms: StateFlow<List<Room>> = chatRepository.chatRooms
var loading by mutableStateOf(true)
var newOtherMessages by mutableIntStateOf(0)
var requireScreening by mutableStateOf(screening)
val messages = mutableStateListOf<UnsignedEvent>()
init {
loadMessages()
connect()
observeNewEvents()
}
private fun loadMessages() {
chatRepository.loadChatRoomMessages(id) { initialMessages ->
messages.clear()
messages.addAll(initialMessages.distinctBy { it.id() })
loading = false
// Mark the room as read once messages are loaded
chatRepository.markAsRead(id)
}
}
private fun connect() {
chatRepository.chatRoomConnect(id)
}
private fun observeNewEvents() {
viewModelScope.launch {
chatRepository.newEvents.collect { event ->
if (event.roomId() == id) {
if (messages.none { it.id() == event.id() }) {
messages.add(0, event)
chatRepository.markAsRead(id)
}
} else {
newOtherMessages++
}
}
}
}
fun sendMessage(text: String, replyTo: EventId? = null) {
val replyToList = if (replyTo != null) listOf(replyTo) else emptyList()
chatRepository.sendMessage(id, text, replyToList)
}
fun sendFileMessage(file: ByteArray?, type: String?) {
chatRepository.sendFileMessage(id, file, type)
}
fun sendReaction(targetEventId: EventId, reaction: String) {
chatRepository.sendReaction(id, targetEventId, reaction)
}
}
@@ -0,0 +1,21 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import rust.nostr.sdk.PublicKey
import su.reya.coop.Room
import su.reya.coop.repository.ChatRepository
import su.reya.coop.repository.ChatState
class ChatViewModel(
private val repository: ChatRepository,
) : ViewModel(), ErrorHost by repository {
val state: StateFlow<ChatState> = repository.state
val chatRooms: StateFlow<List<Room>> = repository.chatRooms
val isSyncing: StateFlow<Boolean> = repository.isSyncing
val isPartialProcessedGiftWrap: StateFlow<Boolean> = repository.isPartialProcessedGiftWrap
fun createChatRoom(to: List<PublicKey>): Long = repository.createChatRoom(to)
fun refreshChatRooms() = repository.refreshChatRooms()
fun resetInternalState() = repository.resetInternalState()
}
@@ -0,0 +1,28 @@
package su.reya.coop.viewmodel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
interface ErrorHost {
val errorEvents: SharedFlow<String>
fun showError(message: String)
}
fun createErrorHost(): ErrorHost = ErrorHostImpl()
private class ErrorHostImpl : ErrorHost {
private val _errorEvents = MutableSharedFlow<String>(
replay = 0,
extraBufferCapacity = 10,
onBufferOverflow = BufferOverflow.SUSPEND
)
override val errorEvents: SharedFlow<String> = _errorEvents.asSharedFlow()
override fun showError(message: String) {
_errorEvents.tryEmit(message)
}
}
@@ -0,0 +1,138 @@
package su.reya.coop.viewmodel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import rust.nostr.sdk.PublicKey
import su.reya.coop.Profile
import su.reya.coop.nostr.Nostr
import kotlin.time.Duration.Companion.milliseconds
class ProfileCache(
private val nostr: Nostr,
defaultDispatcher: CoroutineDispatcher = Dispatchers.Default,
) : ErrorHost by createErrorHost() {
private val scope = CoroutineScope(SupervisorJob() + defaultDispatcher)
private val profiles = MutableStateFlow<Map<PublicKey, MutableStateFlow<Profile?>>>(emptyMap())
private val seenPublicKeys = MutableStateFlow<Set<PublicKey>>(emptySet())
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
init {
scope.launch {
try {
runObserver()
} catch (e: Exception) {
showError("Metadata observer failed: ${e.message}")
}
}
scope.launch {
try {
runMetadataBatching()
} catch (e: Exception) {
showError("Metadata batching failed: ${e.message}")
}
}
scope.launch {
try {
nostr.waitUntilInitialized()
getCachedMetadata()
} catch (e: Exception) {
showError("Failed to load initial cache: ${e.message}")
}
}
}
private suspend fun runObserver() {
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
private suspend fun runMetadataBatching() {
nostr.waitUntilInitialized()
while (true) {
val firstKey = metadataRequestChannel.receive()
val batch = mutableSetOf(firstKey)
while (batch.size < 10) {
val nextKey =
withTimeoutOrNull(500.milliseconds) { metadataRequestChannel.receive() }
?: break
batch.add(nextKey)
}
try {
nostr.profiles.fetchMetadataBatch(batch.toList())
} catch (e: Exception) {
// Allow these keys to be requested again since the fetch failed
seenPublicKeys.update { it - batch }
println("Failed to fetch metadata batch: ${e.message}")
}
}
}
private suspend fun getCachedMetadata() {
val cache = nostr.profiles.getAllCacheMetadata()
cache.forEach { (pubkey, metadata) ->
updateMetadata(pubkey, Profile(pubkey, metadata))
}
}
private fun requestMetadata(pubkey: PublicKey) {
var added = false
seenPublicKeys.update { current ->
if (current.contains(pubkey)) {
added = false
current
} else {
added = true
current + pubkey
}
}
if (added) metadataRequestChannel.trySend(pubkey)
}
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
profiles.update { current ->
val flow = current[pubkey] ?: MutableStateFlow<Profile?>(null)
flow.value = profile
if (current.containsKey(pubkey)) current else current + (pubkey to flow)
}
seenPublicKeys.update { it + pubkey }
}
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
val currentMap = profiles.value
val existingFlow = currentMap[pubkey]
if (existingFlow != null) {
if (existingFlow.value == null) requestMetadata(pubkey)
return existingFlow.asStateFlow()
}
val newFlow = MutableStateFlow<Profile?>(null)
var resultFlow = newFlow
profiles.update { prev ->
if (prev.containsKey(pubkey)) {
resultFlow = prev[pubkey]!!
prev
} else {
prev + (pubkey to newFlow)
}
}
if (resultFlow.value == null) requestMetadata(pubkey)
return resultFlow.asStateFlow()
}
}
@@ -0,0 +1,16 @@
package su.reya.coop.viewmodel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import su.reya.coop.Settings
import su.reya.coop.repository.SettingsRepository
class SettingsViewModel(
private val repository: SettingsRepository
) : ViewModel() {
val settings: StateFlow<Settings> = repository.settings
fun update(transform: (Settings) -> Settings) {
repository.update(transform)
}
}
+70
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