29 Commits

Author SHA1 Message Date
a759ad48e4 chore: bump version 2026-06-09 14:55:47 +07:00
0d6b92b0c7 feat: Add support for NIP-55 (#18)
Reviewed-on: #18
2026-06-09 07:53:48 +00:00
6a69d3a5b2 chore: update nostr sdk 2026-06-08 16:40:56 +07:00
50b7f7a3f3 chore: bump version 2026-06-07 07:37:30 +07:00
a65aa70a55 chore: optimize the battery usage (#16)
Reviewed-on: #16
2026-06-07 00:35:46 +00:00
74a37320fe fix: occasional splash screen hang (#15)
Reviewed-on: #15
2026-06-06 07:30:47 +00:00
b8b3b83952 feat: add update profile screen (#14)
Reviewed-on: #14
2026-06-06 05:50:32 +00:00
5c2115e8b7 chore: bump version 2026-06-04 09:06:00 +07:00
ec337b8756 feat: add self-chat (#13)
Reviewed-on: #13
2026-06-04 01:59:38 +00:00
fcae7d5825 fix: crash when getting all cached metadata (#12)
Reviewed-on: #12
2026-06-03 07:50:34 +00:00
1e90b8d4b1 chore: bump version 2026-06-03 08:35:28 +07:00
71a8240b1d feat: add notification permission banner (#11)
Reviewed-on: #11
2026-06-02 08:54:47 +00:00
ff383a7c6a feat: add crash screen (#10)
Reviewed-on: #10
2026-06-02 02:19:13 +00:00
15e8c984e2 fix: app doesn't navigate to home screen after create or import identity (#9)
Reviewed-on: #9
2026-06-01 13:47:15 +00:00
0da1371345 chore: bump version 2026-06-01 09:55:03 +07:00
b7c5b64022 fix: no member in room (#8)
Reviewed-on: #8
2026-06-01 02:53:56 +00:00
a3ab489d44 feat: migrate to navigation3 (#7)
Reviewed-on: #7
2026-05-31 01:28:09 +00:00
b88674d6e2 chore: bump version
Some checks failed
Build and Release / build (push) Has been cancelled
2026-05-29 13:57:51 +07:00
e9eb071208 feat: implement basic notification (#6)
Reviewed-on: #6
2026-05-29 06:56:47 +00:00
a2a4433a9d feat: add profile screen (#5)
Some checks failed
Build and Release / build (push) Has been cancelled
Reviewed-on: #5
2026-05-27 06:22:14 +00:00
1c08525dfc feat: new onboarding screens (#4)
Changelog:
- Add a custom splash screen
- Redesign the onboarding screen
- Improve UX for the new and import identity screens

Reviewed-on: #4
2026-05-26 02:04:07 +00:00
83af44002c chore: restructure and update dependencies
Some checks failed
Build and Release / build (push) Has been cancelled
2026-05-24 09:32:25 +07:00
44acbfa6b7 chore: fix app crash when create new room via QR (#3)
Reviewed-on: #3
2026-05-24 01:30:48 +00:00
2d25cb36bd chore: bump version
Some checks failed
Build and Release / build (push) Has been cancelled
2026-05-23 20:40:56 +07:00
439cf60b66 chore: fix proguard issues (#2)
Reviewed-on: #2
2026-05-23 13:40:05 +00:00
f1f603525b chore: fix apk signing 2026-05-23 13:45:23 +07:00
5c7027e559 chore: fix build 2026-05-23 13:31:48 +07:00
4bcb2518b7 chore: fix ci 2026-05-23 13:04:27 +07:00
25852e08a9 chore: fix github ci 2026-05-23 12:43:54 +07:00
51 changed files with 2733 additions and 1231 deletions

View File

@@ -1,25 +1,14 @@
name: Build and Release
on:
pull_request:
branches: [ "master" ]
workflow_dispatch:
inputs:
build_type:
description: 'Select build type'
required: true
default: 'release'
type: choice
options:
- release
- alpha
- beta
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
env:
BUILD_TYPE: ${{ github.event.inputs.build_type || 'release' }}
steps:
- uses: actions/checkout@v4
@@ -34,13 +23,23 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Decode Keystore
run: |
# Decodes the Base64 string from secrets to a physical file
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > composeApp/release.jks
- name: Build APK
run: ./gradlew :composeApp:assemble${{ env.BUILD_TYPE }}
run: ./gradlew :composeApp:assembleRelease
env:
KEYSTORE_PATH: release.jks
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
- name: Gitea Release
uses: akkuman/gitea-release-action@v1
with:
files: "composeApp/build/outputs/apk/${{ env.BUILD_TYPE }}/*.apk"
files: "composeApp/build/outputs/apk/release/*.apk"
server_url: "https://git.reya.su/"
repository: "reya/coop-mobile"
token: ${{ secrets.GITEA_TOKEN }}

View File

@@ -1,5 +1,4 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
alias(libs.plugins.kotlinMultiplatform)
@@ -20,15 +19,15 @@ kotlin {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation("androidx.navigation:navigation-compose:2.8.8")
implementation("androidx.datastore:datastore-preferences:1.2.1")
implementation("androidx.datastore:datastore-preferences-core:1.2.1")
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
implementation(libs.androidx.core.splashscreen)
implementation("su.reya:nostr-sdk-kmp:0.2.6")
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.2.3")
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
implementation("io.github.alexzhirkevich:qrose:1.1.2")
}
commonMain.dependencies {
@@ -40,6 +39,8 @@ kotlin {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.datastore)
implementation(projects.shared)
}
commonTest.dependencies {
@@ -48,23 +49,19 @@ kotlin {
}
}
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
load(file.inputStream())
}
}
android {
namespace = "su.reya.coop"
compileSdk = libs.versions.android.compileSdk.get().toInt()
base.archivesName.set("coop")
signingConfigs {
create("release") {
storeFile = localProperties.getProperty("keystore.path")?.let { file(it) }
storePassword = localProperties.getProperty("keystore.password")
keyAlias = localProperties.getProperty("key.alias")
keyPassword = localProperties.getProperty("key.password")
val path = System.getenv("KEYSTORE_PATH")
storeFile = path?.let { file(it) }
storePassword = System.getenv("KEYSTORE_PASSWORD")
keyAlias = System.getenv("KEY_ALIAS")
keyPassword = System.getenv("KEY_PASSWORD")
}
}
defaultConfig {
@@ -72,7 +69,7 @@ android {
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "0.1.0"
versionName = "0.1.8"
}
packaging {
resources {
@@ -84,24 +81,11 @@ android {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt")
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("release")
}
create("beta") {
initWith(getByName("release"))
applicationIdSuffix = ".beta"
versionNameSuffix = "-beta"
manifestPlaceholders["appName"] = "Coop Beta"
signingConfig = signingConfigs.getByName("release")
}
create("alpha") {
initWith(getByName("release"))
applicationIdSuffix = ".alpha"
versionNameSuffix = "-alpha"
manifestPlaceholders["appName"] = "Coop Alpha"
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11

11
composeApp/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,11 @@
-dontwarn com.sun.jna.**
-keep class com.sun.jna.** { *; }
-keep class * extends com.sun.jna.Structure { *; }
-keep class * extends com.sun.jna.Library { *; }
-keep class * extends com.sun.jna.Callback { *; }
-keep class rust.nostr.sdk.** { *; }
-keep class su.reya.nostr.** { *; }
-keepattributes Signature, InnerClasses, EnclosingMethod, RuntimeVisibleAnnotations

View File

@@ -11,6 +11,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<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"
@@ -18,14 +26,35 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".CrashActivity"
android:exported="false"
android:process=":crash_handler"
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
<activity
android:name=".MainActivity"
android:exported="true">
android:exported="true"
android:theme="@style/Theme.App.Starting">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="chat"
android:scheme="coop" />
</intent-filter>
</activity>
<service
android:name=".NostrForegroundService"
android:enabled="true"

View File

@@ -1,18 +1,3 @@
<!--
~ Copyright (C) 2026 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="750dp"
android:height="750dp"

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,360Q80,327 103.5,303.5Q127,280 160,280L360,280L360,160Q360,127 383.5,103.5Q407,80 440,80L520,80Q553,80 576.5,103.5Q600,127 600,160L600,280L800,280Q833,280 856.5,303.5Q880,327 880,360L880,800Q880,833 856.5,856.5Q833,880 800,880L160,880ZM160,800L800,800Q800,800 800,800Q800,800 800,800L800,360Q800,360 800,360Q800,360 800,360L600,360L600,360Q600,393 576.5,416.5Q553,440 520,440L440,440Q407,440 383.5,416.5Q360,393 360,360L360,360L160,360Q160,360 160,360Q160,360 160,360L160,800Q160,800 160,800Q160,800 160,800ZM240,720L480,720L480,702Q480,685 470.5,670.5Q461,656 444,648Q424,639 403.5,634.5Q383,630 360,630Q337,630 316.5,634.5Q296,639 276,648Q259,656 249.5,670.5Q240,685 240,702L240,720ZM560,660L720,660L720,600L560,600L560,660ZM402.5,582.5Q420,565 420,540Q420,515 402.5,497.5Q385,480 360,480Q335,480 317.5,497.5Q300,515 300,540Q300,565 317.5,582.5Q335,600 360,600Q385,600 402.5,582.5ZM560,540L720,540L720,480L560,480L560,540ZM440,360L520,360L520,160L440,160L440,360ZM480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M360,840L360,760L240,760L240,680L320,680L320,280L240,280L240,200L360,200L360,120L440,120L440,200L520,200L520,120L600,120L600,205L600,205Q652,219 686,261.5Q720,304 720,360Q720,389 710,415.5Q700,442 682,463Q717,484 738.5,520Q760,556 760,600Q760,666 713,713Q666,760 600,760L600,760L600,840L520,840L520,760L440,760L440,840L360,840ZM400,440L560,440Q593,440 616.5,416.5Q640,393 640,360Q640,327 616.5,303.5Q593,280 560,280L400,280L400,440ZM400,680L600,680Q633,680 656.5,656.5Q680,633 680,600Q680,567 656.5,543.5Q633,520 600,520L400,520L400,680Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,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,473 799.5,465.5Q799,458 799,453Q794,482 772,501Q750,520 720,520L640,520Q607,520 583.5,496.5Q560,473 560,440L560,400L400,400L400,320Q400,287 423.5,263.5Q447,240 480,240L520,240L520,240Q520,217 532.5,199.5Q545,182 563,171Q543,166 522.5,163Q502,160 480,160Q346,160 253,253Q160,346 160,480Q160,480 160,480Q160,480 160,480L360,480Q426,480 473,527Q520,574 520,640L520,680L400,680L400,790Q420,795 439.5,797.5Q459,800 480,800Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z" />
</vector>

View File

@@ -0,0 +1,147 @@
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
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 it.result
}
// Fall back to Intent
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
`package` = cachedPackageName
putExtra("type", type)
if (pubkey != null) putExtra("pubkey", pubkey.toHex())
if (currentUser != null) putExtra("current_user", currentUser.toHex())
extras.forEach { (k, v) -> putExtra(k, v) }
}
val result = launcher.launch(intent)
if (result.resultCode != Activity.RESULT_OK) return null
val data = result.data ?: return null
if (data.getBooleanExtra("rejected", false)) return null
return data.getStringExtra(resultKey)
}
override fun isAvailable(): Boolean {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
}
override fun setPackageName(packageName: String) {
cachedPackageName = packageName
}
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
putExtra("type", "get_public_key")
if (permissions != null) putExtra("permissions", permissions)
}
val result = launcher.launch(intent)
if (result.resultCode != Activity.RESULT_OK) return null
val data = result.data ?: return null
if (data.getBooleanExtra("rejected", false)) return null
val pubkey = data.getStringExtra("result") ?: return null
val packageName = data.getStringExtra("package") ?: return null
cachedPackageName = packageName
return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
}
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
return request(
type = "sign_event",
payload = event.asJson(),
currentUser = currentUser,
resultKey = "event",
extras = extras,
)
}
override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
return request("nip04_encrypt", plaintext, pubkey)
}
override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
return request("nip04_decrypt", ciphertext, pubkey)
}
override suspend fun nip44Encrypt(
plaintext: String,
pubkey: PublicKey,
currentUser: PublicKey
): String? {
return request("nip44_encrypt", plaintext, pubkey, currentUser)
}
override suspend fun nip44Decrypt(
ciphertext: String,
pubkey: PublicKey,
currentUser: PublicKey
): String? {
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
}
}

View File

@@ -1,5 +1,10 @@
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
@@ -15,8 +20,10 @@ 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
@@ -25,10 +32,11 @@ 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
@@ -37,14 +45,16 @@ 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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import kotlinx.coroutines.launch
import su.reya.coop.coop.storage.SecretStore
import su.reya.coop.screens.ChatScreen
import su.reya.coop.screens.HomeScreen
import su.reya.coop.screens.ImportScreen
@@ -52,8 +62,10 @@ import su.reya.coop.screens.MyQrScreen
import su.reya.coop.screens.NewChatScreen
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.ScanScreen
import su.reya.coop.screens.UpdateProfileScreen
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided")
@@ -63,64 +75,150 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided")
}
val LocalNavController = staticCompositionLocalOf<NavController> {
error("No NavController provided")
val LocalNavigator = staticCompositionLocalOf<Navigator> {
error("No Navigator provided")
}
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
error("No QrScanResult provided")
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun App() {
fun App(viewModel: NostrViewModel) {
val context = LocalContext.current
val navController = rememberNavController()
val activity = context as? ComponentActivity
val scope = rememberCoroutineScope()
val darkMode = isSystemInDarkTheme()
val sheetState = rememberModalBottomSheetState()
val backStack = rememberNavBackStack(Screen.Home)
val navigator = remember(backStack) { Navigator(backStack) }
val qrScanResult = remember { QrScanResult() }
val signerRequired by viewModel.signerRequired.collectAsStateWithLifecycle()
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsStateWithLifecycle()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Initialize Nostr View Model and Secret Store
val secretStore = remember { SecretStore(context) }
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
// Check if dark theme enabled
val darkMode = isSystemInDarkTheme()
// Enabled the dynamic color scheme
val colorScheme = when {
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
// Enable the dynamic color scheme for Android 12+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
context
)
}
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme()
// Fallback to the light color scheme
else -> expressiveLightColorScheme()
}
BackHandler(enabled = backStack.size > 1) {
navigator.goBack()
}
LaunchedEffect(Unit) {
viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message)
}
}
LaunchedEffect(activity) {
activity?.let {
fun handleIntent(intent: Intent) {
val screen = Screen.fromIntent(intent)
// Prevent pushing the same screen
if (screen != null && backStack.lastOrNull() != screen) {
navigator.navigate(screen)
}
}
// Handle the intent that started the Activity
handleIntent(it.intent)
// Handle new intents while the Activity is running
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
it.addOnNewIntentListener(listener)
}
}
LaunchedEffect(backStack.size) {
if (backStack.isEmpty()) {
(context as? Activity)?.finish()
}
}
LaunchedEffect(signerRequired) {
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
backStack.clear()
backStack.add(Screen.Onboarding)
}
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography(),
motionScheme = MotionScheme.expressive(),
) {
CompositionLocalProvider(
LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController,
LocalNavigator provides navigator,
LocalScanResult provides qrScanResult,
) {
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
val sheetState = rememberModalBottomSheetState()
LaunchedEffect(emptySecret) {
// Navigate to the home screen if the secret is already set
if (emptySecret == false) {
navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true }
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
(context as? Activity)?.finish()
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider {
entry<Screen.Home> {
HomeScreen()
}
entry<Screen.Onboarding> {
OnboardingScreen()
}
entry<Screen.Import> {
ImportScreen()
}
entry<Screen.NewIdentity> {
NewIdentityScreen()
}
entry<Screen.Chat> { key ->
ChatScreen(id = key.id)
}
entry<Screen.NewChat> {
NewChatScreen()
}
entry<Screen.Profile> { key ->
ProfileScreen(pubkey = key.pubkey)
}
entry<Screen.UpdateProfile> {
UpdateProfileScreen()
}
entry<Screen.Scan> {
ScanScreen()
}
entry<Screen.MyQr> {
MyQrScreen()
}
entry<Screen.Relay> {
RelayScreen()
}
}
}
// Show loading screen while initializing
if (emptySecret == null) return@CompositionLocalProvider
)
// Show the relay setup dialog if the msg relay list is empty
if (isRelayListEmpty) {
@@ -176,79 +274,26 @@ fun App() {
}
}
}
NavHost(
navController = navController,
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
) {
composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen(
onOpenImport = { navController.navigate(Screen.Import) },
onOpenNew = { navController.navigate(Screen.NewIdentity) }
)
}
composable<Screen.Import> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
ImportScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
}
composable<Screen.NewIdentity> { backStackEntry ->
val isCreating by viewModel.isCreating.collectAsState()
NewIdentityScreen(
isLoading = isCreating,
onBack = { navController.popBackStack() },
onSave = { name, bio, uri ->
val contentType = uri?.let { context.contentResolver.getType(it) }
val picture = uri?.let {
context.contentResolver.openInputStream(it)?.use { input ->
input.readBytes()
}
}
viewModel.createIdentity(name, bio, picture, contentType)
}
)
}
composable<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
onNewChat = { navController.navigate(Screen.NewChat) }
)
}
composable<Screen.Chat> { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(
id = chat.id,
onBack = { navController.popBackStack() },
)
}
composable<Screen.NewChat> { backStackEntry ->
NewChatScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Scan> { backStackEntry ->
ScanScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.MyQr> { backStackEntry ->
MyQrScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Relay> { backStackEntry ->
RelayScreen(
onBack = { navController.popBackStack() },
)
}
}
}
}
}
class Navigator(private val backStack: NavBackStack<NavKey>) {
fun navigate(route: NavKey) {
backStack.add(route)
}
fun goBack() {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
}
}
}
class QrScanResult {
var content by mutableStateOf<String?>(null)
fun clear() {
content = null
}
}

View File

@@ -0,0 +1,108 @@
package su.reya.coop
import android.content.ClipData
import android.content.ClipboardManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.system.exitProcess
class CrashActivity : ComponentActivity() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val errorText = intent.getStringExtra("error") ?: "Unknown error"
setContent {
MaterialExpressiveTheme {
Scaffold(
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Column {
Text(
"App Crashed",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.error
)
Text(
"Please copy the log below and send it to the developer.",
style = MaterialTheme.typography.bodySmall
)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
Text(
text = errorText,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilledTonalButton(
onClick = {
finish();
exitProcess(0)
},
modifier = Modifier.weight(1f)
) {
Text("Exit")
}
Button(
onClick = {
val clipboard =
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
val data = ClipData.newPlainText("Crash Log", errorText)
clipboard.setPrimaryClip(data)
},
modifier = Modifier.weight(1f)
) {
Text("Copy")
}
}
}
}
}
)
}
}
}
}

View File

@@ -0,0 +1,28 @@
package su.reya.coop
import android.content.Intent
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import kotlinx.coroutines.CompletableDeferred
class ExternalSignerLauncher {
private var launcher: ActivityResultLauncher<Intent>? = null
private var pendingResult: CompletableDeferred<ActivityResult>? = null
fun register(launcher: ActivityResultLauncher<Intent>) {
this.launcher = launcher
}
suspend fun launch(intent: Intent): ActivityResult {
val deferred = CompletableDeferred<ActivityResult>()
pendingResult = deferred
launcher?.launch(intent)
?: throw IllegalStateException("ExternalSignerLauncher not registered")
return deferred.await()
}
fun onResult(result: ActivityResult) {
pendingResult?.complete(result)
pendingResult = null
}
}

View File

@@ -1,27 +1,86 @@
package su.reya.coop
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import su.reya.coop.coop.storage.SecretStore
import kotlin.system.exitProcess
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
companion object {
val externalSignerLauncher = ExternalSignerLauncher()
}
val intent = Intent(this, NostrForegroundService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
setContent {
App()
private val viewModel: NostrViewModel by viewModels {
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secretStore = SecretStore(this@MainActivity)
val androidSigner = AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
return NostrViewModel(NostrManager.instance, secretStore, androidSigner) as T
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
throwable.printStackTrace()
Log.e(
"CoopCrash", "Uncaught exception in thread ${thread.name}", throwable
)
// Start the Crash Activity
val intent = Intent(this, CrashActivity::class.java).apply {
putExtra("error", throwable.stackTraceToString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
startActivity(intent)
// Exit
Process.killProcess(Process.myPid())
exitProcess(1)
}
val resultLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
externalSignerLauncher.onResult(result)
}
externalSignerLauncher.register(resultLauncher)
val splashScreen = installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val serviceIntent = Intent(this, NostrForegroundService::class.java)
startForegroundService(serviceIntent)
// Keep the splash screen visible until the signer check is complete
splashScreen.setKeepOnScreenCondition {
viewModel.signerRequired.value == null
}
// Bind the lifecycle of the ViewModel to the Activity's lifecycle'
viewModel.bindLifecycle(ProcessLifecycleOwner.get().lifecycle)
setContent {
App(viewModel = viewModel)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}

View File

@@ -1,14 +1,37 @@
package su.reya.coop
import android.content.Intent
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
sealed interface Screen {
sealed interface Screen : NavKey {
companion object {
fun fromIntent(intent: Intent): Screen? {
val data = intent.data ?: return null
if (data.scheme != "coop") return null
return when (data.host) {
// Matches coop://chat/{id}
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) }
// Matches coop://profile/{pubkey}
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
else -> null
}
}
}
@Serializable
data object Home : Screen
@Serializable
data class Chat(val id: Long) : Screen
@Serializable
data class Profile(val pubkey: String) : Screen
@Serializable
data object UpdateProfile : Screen
@Serializable
data object NewChat : Screen

View File

@@ -3,16 +3,20 @@ package su.reya.coop
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.annotation.RequiresApi
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@@ -20,7 +24,8 @@ import java.io.File
class NostrForegroundService : Service() {
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val nostr = NostrManager.instance
private val nostr by lazy { NostrManager.instance }
private var notificationJob: Job? = null
override fun onBind(intent: Intent?): IBinder? = null
@@ -28,60 +33,129 @@ class NostrForegroundService : Service() {
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
@RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel()
val notification = createNotification("Connecting to Nostr...")
startForeground(1, notification)
override fun onCreate() {
super.onCreate()
serviceScope.launch {
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)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (notificationJob?.isActive == true) return START_STICKY
notificationJob = serviceScope.launch {
try {
Log.d("Coop", "Starting Nostr in background")
// Create a database directory
val dbDir = File(filesDir, "nostr")
dbDir.mkdirs()
// Initialize Nostr client
nostr.init(dbDir.absolutePath)
try {
nostr.init(dbDir.absolutePath)
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr Client", e)
}
// Connect to bootstrap relays
nostr.connectBootstrapRelays()
// Handle notifications
nostr.handleLiteNotifications { event ->
if (!isUserInApp()) {
showNewMessageNotification(event.content())
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
},
onSubscriptionClose = {
serviceScope.launch { nostr.emitSubscriptionClosed() }
},
onNewMessage = { event ->
serviceScope.launch {
if (!isUserInApp()) {
showNewMessageNotification(event.roomId(), event.content())
}
nostr.emitNewEvent(event)
}
}
}
)
} catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}")
Log.e("Coop", "Failed to start Nostr", e)
}
}
return START_STICKY
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel() {
val channel = NotificationChannel(
val manager = getSystemService(NotificationManager::class.java)
val serviceChannel = NotificationChannel(
"nostr_service",
"Nostr Background Service",
"Nostr Background Status",
NotificationManager.IMPORTANCE_MIN
).apply {
setShowBadge(false)
}
manager?.createNotificationChannel(serviceChannel)
val messageChannel = NotificationChannel(
"nostr_messages",
"New Messages",
NotificationManager.IMPORTANCE_HIGH
)
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
manager?.createNotificationChannel(messageChannel)
}
private fun createNotification(content: String): Notification {
return NotificationCompat.Builder(this, "nostr_service")
.setContentTitle("Coop")
.setContentText(content)
.setSmallIcon(android.R.drawable.ic_menu_send)
private fun createNotification(content: String? = null): Notification {
val builder = NotificationCompat.Builder(this, "nostr_service")
.setSmallIcon(R.drawable.ic_notification)
.setOngoing(true)
.build()
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(Notification.CATEGORY_SERVICE)
if (content != null) {
builder.setContentTitle("Coop")
builder.setContentText(content)
} else {
builder.setContentTitle("Coop is active")
}
return builder.build()
}
private fun showNewMessageNotification(message: String) {
val notification = NotificationCompat.Builder(this, "nostr_service")
.setContentTitle("New Message")
private fun showNewMessageNotification(roomId: Long, message: String) {
val deepLinkUri = "coop://chat/$roomId".toUri()
val intent = Intent(
Intent.ACTION_VIEW,
deepLinkUri,
this,
MainActivity::class.java
)
val pendingIntent = PendingIntent.getActivity(
this,
roomId.toInt(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "nostr_messages")
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("You received a new message")
.setContentText(message)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_MESSAGE)
.build()
val manager = getSystemService(NotificationManager::class.java)
manager?.notify(System.currentTimeMillis().toInt(), notification)
}

View File

@@ -1,6 +1,7 @@
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
@@ -19,6 +20,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.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -36,7 +39,9 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -44,15 +49,19 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_send
import 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
@@ -61,34 +70,49 @@ import su.reya.coop.shared.pictureFlow
import su.reya.coop.short
@Composable
fun ChatScreen(
id: Long,
onBack: () -> Unit,
) {
fun ChatScreen(id: Long) {
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val room = viewModel.getChatRoom(id)
val listState = rememberLazyListState()
// Get chat room by ID
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val room by remember(id) {
derivedStateOf { chatRooms.firstOrNull { it.id == id } }
}
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
// Show empty screen
if (room == null) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Chat room not found",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSurface
)
}
return
}
val displayName by remember(room) { room!!.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room!!.pictureFlow(viewModel) }.collectAsState(null)
var text by remember { mutableStateOf("") }
var loading by remember { mutableStateOf(true) }
var newOtherMessages by remember { mutableIntStateOf(0) }
val listState = rememberLazyListState()
val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) {
messages.groupBy { it.createdAt().formatAsGroupHeader() }
}
fun setLoading(value: Boolean) {
loading = value
}
LaunchedEffect(id) {
// Start loading spinner
setLoading(true)
loading = true
// Get messages
val initialMessages = viewModel.getChatRoomMessages(id)
@@ -108,7 +132,7 @@ fun ChatScreen(
}
// Stop loading spinner
setLoading(false)
loading = false
// Handle new messages
viewModel.newEvents.collect { event ->
@@ -116,6 +140,9 @@ fun ChatScreen(
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++
}
}
}
@@ -132,7 +159,14 @@ fun ChatScreen(
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
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),
@@ -152,11 +186,21 @@ fun ChatScreen(
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
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(
@@ -204,10 +248,15 @@ fun ChatScreen(
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = "No messages yet",
style = MaterialTheme.typography.titleLargeEmphasized,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(

View File

@@ -1,5 +1,12 @@
package su.reya.coop.screens
import android.Manifest
import android.content.ClipData
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -8,12 +15,15 @@ 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -35,6 +45,7 @@ import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
@@ -58,9 +69,14 @@ 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.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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_new_chat
import coop.composeapp.generated.resources.ic_qr
@@ -68,8 +84,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room
import su.reya.coop.Screen
@@ -81,51 +98,64 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
onOpenChat: (Long) -> Unit,
onNewChat: () -> Unit,
) {
val clipboard = LocalClipboard.current
val navController = LocalNavController.current
fun HomeScreen() {
val context = LocalContext.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val snackbarHostState = LocalSnackbarHostState.current
val clipboardManager = LocalClipboard.current
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
val userProfile by currentUserProfile.collectAsState(initial = null)
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
val userProfile by currentUserProfile.collectAsStateWithLifecycle()
val chatRooms by viewModel.chatRooms.collectAsStateWithLifecycle()
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
val isBannerDismissed by viewModel.isNotificationBannerDismissed.collectAsState()
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState()
val pullToRefreshState = rememberPullToRefreshState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
var isNotificationEnabled by remember {
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
}
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { _ ->
// State will be updated by LifecycleResumeEffect
}
LifecycleResumeEffect(context) {
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
onPauseOrDispose { }
}
LaunchedEffect(Unit) {
viewModel.getChatRooms()
}
LaunchedEffect(qrResult) {
qrResult?.let { result ->
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching { PublicKey.parse(result) }
.onSuccess { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
try {
val roomId = viewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
}
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
qrScanResult.clear()
}
}
@@ -145,7 +175,7 @@ fun HomeScreen(
},
actions = {
// QR Scanner
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
@@ -176,7 +206,7 @@ fun HomeScreen(
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = onNewChat,
onClick = { navigator.navigate(Screen.NewChat) },
expanded = expandedFab,
icon = {
Icon(
@@ -189,159 +219,229 @@ fun HomeScreen(
}
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
Column(
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
if (!isNotificationEnabled && !isBannerDismissed) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
text = "Get message notifications",
style = MaterialTheme.typography.titleMediumEmphasized,
color = MaterialTheme.colorScheme.onSecondaryFixed,
)
Text(
text = "Your conversations will appear here.",
text = "Make sure you know when you have new messages.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { onOpenChat(room.id) }
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
onClick = { viewModel.dismissNotificationBanner() },
modifier = Modifier.weight(1f),
) {
Text(text = "Maybe later")
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
// For older versions, navigate the user directly to App Notification Settings
val intent =
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(
Settings.EXTRA_APP_PACKAGE,
context.packageName
)
}
context.startActivity(intent)
}
},
modifier = Modifier.weight(1f),
) {
Text(text = "Turn on")
}
}
}
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
) {
val pubkey = viewModel.currentUser()
val shortPubkey = pubkey?.short() ?: "Not available"
val userName =
userProfile?.asRecord()?.displayName
?: userProfile?.asRecord()?.name
?: "No name"
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
PullToRefreshBox(
modifier = Modifier.fillMaxSize(),
isRefreshing = isRefreshing,
state = pullToRefreshState,
onRefresh = {
scope.launch {
sheetState.hide()
showBottomSheet = false
action()
isRefreshing = true
viewModel.refreshChatRooms()
isRefreshing = false
}
},
indicator = {
PullToRefreshDefaults.LoadingIndicator(
state = pullToRefreshState,
isRefreshing = isRefreshing,
modifier = Modifier.align(Alignment.TopCenter),
)
}
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!isPartialProcessedGiftWrap) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(84.dp)
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = userProfile?.asRecord()?.picture,
description = userProfile?.asRecord()?.displayName,
shape = MaterialShapes.Cookie9Sided.toShape(),
modifier = Modifier.fillMaxSize()
)
}
Spacer(modifier = Modifier.size(8.dp))
Box(
contentAlignment = Alignment.Center
LoadingIndicator()
}
} else if (chatRooms.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = userName,
style = MaterialTheme.typography.titleLargeEmphasized,
text = "No chats yet",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold
),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Your conversations will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
dismissAndRun { navController.navigate(Screen.MyQr) }
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
scope.launch {
sheetState.hide()
showBottomSheet = false
navController.navigate(Screen.MyQr)
}
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize()
) {
items(chatRooms.toList(), key = { it.id }) { room ->
ChatRoom(
room = room,
onClick = { navigator.navigate(Screen.Chat(room.id)) }
)
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
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))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
onClick = {
scope.launch {
pubkey?.let {
val bech32 = it.toBech32()
val data =
ClipData.newPlainText(bech32, bech32)
clipboardManager.setClipEntry(ClipEntry(data))
}
}
},
) {
Text(text = shortPubkey)
}
FilledIconButton(
onClick = {
dismissAndRun { navigator.navigate(Screen.MyQr) }
},
shape = MaterialShapes.Square.toShape()
) {
Icon(
painter = painterResource(Res.drawable.ic_qr),
contentDescription = "My QR"
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
BottomMenuList(onDismiss = dismissAndRun)
}
}
}
}
@@ -398,13 +498,14 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
fun BottomMenuList(
onDismiss: (suspend () -> Unit) -> Unit
) {
val navController = LocalNavController.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val defaultMenuList = listOf(
"Relay Management" to { navController.navigate(Screen.Relay) },
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
"Contact List" to { },
"Spams & Blocks" to { },
"Contacts" to { },
"Relay Management" to { navigator.navigate(Screen.Relay) },
"Settings" to { }
)

View File

@@ -7,11 +7,14 @@ 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
@@ -30,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -40,61 +42,57 @@ 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.MutableStateFlow
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.LocalNavController
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (secret: String) -> Unit
) {
fun ImportScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val focusManager = LocalFocusManager.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
val metadata by remember(pubkey) {
if (pubkey != null) {
viewModel.getMetadata(pubkey!!)
} else {
MutableStateFlow(null)
}
}.collectAsState(null)
// 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
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(qrResult) {
qrResult?.let { result ->
LaunchedEffect(qrScanResult.content) {
qrScanResult.content?.let { result ->
runCatching {
if (result.startsWith("nsec")) {
Keys.parse(result)
@@ -106,8 +104,9 @@ fun ImportScreen(
}
.onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
qrScanResult.clear()
}
}
@@ -126,7 +125,7 @@ fun ImportScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
@@ -134,7 +133,7 @@ fun ImportScreen(
}
},
actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
@@ -145,40 +144,44 @@ fun ImportScreen(
},
content = { innerPadding ->
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()),
.weight(1f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape()),
.clip(MaterialShapes.Cookie9Sided.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Pentagon.toShape(),
shape = MaterialShapes.Cookie9Sided.toShape(),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
.fillMaxWidth()
.weight(1f, fill = false),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
@@ -186,64 +189,82 @@ fun ImportScreen(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Enter your Secret Key or Bunker URI:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = secret,
onValueChange = { secret = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (secret.isEmpty()) {
Text(
"bunker://",
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Enter your Secret Key or Bunker URI:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
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) {
if (secret.isEmpty()) {
Text(
"bunker://",
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.weight(1f))
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
if (pubkey == null) {
scope.launch {
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)
}
} else {
onSave(secret)
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading,
enabled = secret.isNotBlank() && !isBusy,
) {
if (isLoading) {
if (isBusy) {
LoadingIndicator()
} else {
Text(
text = if (pubkey == null) "Verify" else "Continue",
text = if (pubkey == null) "Verify" else "Click again to Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}

View File

@@ -19,13 +19,13 @@ 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
@Composable
fun MyQrScreen(
onBack: () -> Unit
) {
fun MyQrScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
val currentUser = viewModel.currentUser() ?: return
@@ -41,7 +41,7 @@ fun MyQrScreen(
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"

View File

@@ -54,8 +54,9 @@ import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
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
@@ -63,11 +64,10 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewChatScreen(
onBack: () -> Unit,
) {
fun NewChatScreen() {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val navigator = LocalNavigator.current
val qrScanResult = LocalScanResult.current
val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
@@ -76,12 +76,6 @@ fun NewChatScreen(
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(query) {
if (query.length >= 3) {
delay(500) // 500ms debounce
@@ -111,13 +105,19 @@ fun NewChatScreen(
}
}
LaunchedEffect(qrResult) {
qrResult?.let { result ->
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}") }
.onSuccess { pubkey ->
selectedReceivers.add(pubkey)
}
.onFailure { e ->
println("Failed to parse QR: ${e.message}")
}
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
qrScanResult.clear()
}
}
@@ -136,7 +136,7 @@ fun NewChatScreen(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
@@ -144,7 +144,7 @@ fun NewChatScreen(
}
},
actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
@@ -168,7 +168,7 @@ fun NewChatScreen(
ExtendedFloatingActionButton(
onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
navController.navigate(Screen.Chat(roomId))
navigator.navigate(Screen.Chat(roomId))
},
expanded = false,
icon = {
@@ -259,7 +259,7 @@ fun NewChatScreen(
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
navigator.navigate(Screen.Chat(roomId))
},
)
Spacer(modifier = Modifier.size(16.dp))
@@ -270,7 +270,7 @@ fun NewChatScreen(
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
navigator.navigate(Screen.Chat(roomId))
}
)
Spacer(modifier = Modifier.size(16.dp))

View File

@@ -1,244 +1,31 @@
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.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.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.Screen
import su.reya.coop.shared.ProfileEditor
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewIdentityScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
fun NewIdentityScreen() {
val viewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
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 = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
},
content = { innerPadding ->
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
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
.weight(1f)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What others should call you?",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
Text(
"Alice",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
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.weight(1f))
Button(
onClick = {
onSave(name, bio, picture)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
ProfileEditor(
title = "Create a new identity",
buttonLabel = "Continue",
isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
viewModel.createIdentity(name, bio, bytes, type)
navigator.navigate(Screen.Home)
}
}
)

View File

@@ -1,5 +1,6 @@
package su.reya.coop.screens
import android.content.Intent
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -9,16 +10,21 @@ 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.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.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.ui.Alignment
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@@ -26,27 +32,77 @@ 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
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.coop
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
fun OnboardingScreen() {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navigator = LocalNavigator.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
val logoPainter = painterResource(Res.drawable.coop)
val expressiveFont = getExpressiveFontFamily()
val annotatedText = buildAnnotatedString {
append("By using Coop, you agree to accept\nour ")
// Push "Terms of Use" link
pushLink(
LinkAnnotation.Url(
url = "https://coop.free/terms",
styles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold,
)
)
)
)
append("Terms of Use")
pop()
append(" and ")
// Push "Privacy Policy" link
pushLink(
LinkAnnotation.Url(
url = "https://coop.free/privacy",
styles = TextLinkStyles(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSecondaryContainer,
fontWeight = FontWeight.SemiBold,
)
)
)
)
append("Privacy Policy")
pop()
append(".")
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
Box(modifier = Modifier.fillMaxSize()) {
LogoRepeatingBackground(
painter = logoPainter,
logosPerRow = 6,
@@ -54,55 +110,108 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
horizontalOffset = 0.5f
)
Column(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding() + 16.dp),
) {
Box(
Spacer(modifier = Modifier.weight(2f))
Surface(
modifier = Modifier
.weight(2f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
// TODO: Add headline
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(bottom = innerPadding.calculateBottomPadding()),
contentAlignment = Alignment.BottomEnd,
.padding(24.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
shadowElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
) {
Text(
text = "Get Started",
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
fontFamily = expressiveFont,
),
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Coop is a secure and easy to use messaging app. All your communications are encrypted and private by default.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(24.dp))
Button(
onClick = onOpenNew,
onClick = { navigator.navigate(Screen.NewIdentity) },
modifier = Modifier
.fillMaxWidth()
.size(ButtonDefaults.LargeContainerHeight),
.size(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Start messaging",
style = MaterialTheme.typography.titleLargeEmphasized,
text = "Start Messaging",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
Spacer(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton(
onClick = onOpenImport,
onClick = {
scope.launch {
if (viewModel.isExternalSignerAvailable()) {
try {
viewModel.connectExternalSigner()
navigator.navigate(Screen.Home)
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
} else {
val result = snackbarHostState.showSnackbar(
message = "External signer not installed. Please install Amber or alternatives.",
actionLabel = "Install",
withDismissAction = true,
duration = SnackbarDuration.Long
)
if (result == SnackbarResult.ActionPerformed) {
val intent = Intent(
Intent.ACTION_VIEW,
"https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
)
context.startActivity(intent)
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.LargeContainerHeight),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Import identity",
style = MaterialTheme.typography.titleLargeEmphasized,
text = "Connect with Amber",
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.size(8.dp))
TextButton(
onClick = { navigator.navigate(Screen.Import) },
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Text(
text = "Add an Existing Identity",
style = MaterialTheme.typography.titleMedium,
)
}
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@@ -116,7 +225,7 @@ fun LogoRepeatingBackground(
rotationDegrees: Float = 0f,
horizontalOffset: Float = 0.5f
) {
val tintColor = MaterialTheme.colorScheme.primary
val tintColor = MaterialTheme.colorScheme.onSecondaryContainer
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width

View File

@@ -0,0 +1,240 @@
package su.reya.coop.screens
import android.content.Intent
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.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_chat
import coop.composeapp.generated.resources.ic_share
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileScreen(pubkey: String) {
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 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 details = remember(profile) {
listOf(
"Username:" to (profile?.name ?: "None"),
"Website:" to (profile?.website ?: "None"),
"Lightning Address:" to (profile?.lud16 ?: "None"),
)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { },
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(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.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))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontFamily = getExpressiveFontFamily()
),
)
Text(
text = nip05,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall
)
}
Spacer(modifier = Modifier.size(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
FilledTonalIconButton(
onClick = {
scope.launch {
try {
val roomId = viewModel.createChatRoom(listOf(pubkey))
navigator.navigate(Screen.Chat(roomId))
} catch (e: Exception) {
e.message?.let { snackbarHostState.showSnackbar(it) }
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Icon(
painter = painterResource(Res.drawable.ic_chat),
contentDescription = "New Chat"
)
}
Text(
text = "Message",
style = MaterialTheme.typography.labelMedium
)
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
FilledTonalIconButton(
onClick = {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, pubkey.toBech32())
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
) {
Icon(
painter = painterResource(Res.drawable.ic_share),
contentDescription = "Share"
)
}
Text(
text = "Share",
style = MaterialTheme.typography.labelMedium
)
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.weight(1.5f),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
details.forEachIndexed { index, (label, value) ->
SegmentedListItem(
onClick = { },
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = details.size
),
content = { Text(label) },
supportingContent = { Text(value) },
)
}
}
}
}
}
)
}

View File

@@ -34,14 +34,14 @@ import coop.composeapp.generated.resources.ic_arrow_back
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
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RelayScreen(
onBack: () -> Unit
) {
fun RelayScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current
@@ -80,7 +80,7 @@ fun RelayScreen(
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"

View File

@@ -30,21 +30,16 @@ import org.jetbrains.compose.resources.painterResource
import org.publicvalue.multiplatform.qrcode.CameraPosition
import org.publicvalue.multiplatform.qrcode.CodeType
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalScanResult
import su.reya.coop.LocalSnackbarHostState
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun ScanScreen(
onBack: () -> Unit
) {
val navController = LocalNavController.current
fun ScanScreen() {
val navigator = LocalNavigator.current
val snackbarHostState = LocalSnackbarHostState.current
val onResult: (String) -> Unit = { result ->
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
navController.popBackStack()
}
val qrScanResult = LocalScanResult.current
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -57,7 +52,7 @@ fun ScanScreen(
)
},
navigationIcon = {
IconButton(onClick = onBack) {
IconButton(onClick = { navigator.goBack() }) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
@@ -76,8 +71,8 @@ fun ScanScreen(
ScannerWithPermissions(
modifier = Modifier.fillMaxSize(),
onScanned = {
println("Scanned: $it");
onResult(it)
qrScanResult.content = it
navigator.goBack()
true
},
types = listOf(CodeType.QR),

View File

@@ -0,0 +1,40 @@
package su.reya.coop.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import su.reya.coop.LocalNavigator
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.shared.ProfileEditor
@Composable
fun UpdateProfileScreen() {
val viewModel = LocalNostrViewModel.current
val navigator = LocalNavigator.current
val scope = rememberCoroutineScope()
val currentUser = viewModel.currentUser() ?: return
val metadata by viewModel.getMetadata(currentUser).collectAsState(initial = null)
val isBusy by viewModel.isBusy.collectAsStateWithLifecycle(false)
val profile = metadata?.asRecord()
ProfileEditor(
title = "Update profile",
buttonLabel = "Save changes",
initialName = profile?.displayName ?: profile?.name ?: "",
initialBio = profile?.about ?: "",
initialPicture = profile?.picture,
isBusy = isBusy,
onBack = { navigator.goBack() },
onConfirm = { name, bio, bytes, type ->
scope.launch {
viewModel.updateProfile(name, bio, bytes, type)
navigator.goBack()
}
}
)
}

View File

@@ -0,0 +1,13 @@
package su.reya.coop.shared
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import coop.composeapp.generated.resources.PaytoneOne_Regular
import coop.composeapp.generated.resources.Res
import org.jetbrains.compose.resources.Font
@Composable
fun getExpressiveFontFamily() = FontFamily(
Font(Res.font.PaytoneOne_Regular, FontWeight.Normal)
)

View File

@@ -0,0 +1,294 @@
package su.reya.coop.shared
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_plus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileEditor(
title: String,
buttonLabel: String,
initialName: String = "",
initialBio: String = "",
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
isBusy: Boolean = false,
onBack: () -> Unit,
onConfirm: (name: String, bio: String, pictureBytes: ByteArray?, contentType: String?) -> Unit
) {
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val focusManager = LocalFocusManager.current
var name by remember(initialName) { mutableStateOf(initialName) }
var bio by remember(initialBio) { mutableStateOf(initialBio) }
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
val hasPicture = remember(picture) {
when (picture) {
null -> false
is String -> (picture as CharSequence).isNotBlank()
else -> true
}
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
picture = uri
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text(title, style = MaterialTheme.typography.titleMediumEmphasized) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
)
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape())
.clickable { launcher.launch("image/*") },
contentAlignment = Alignment.Center
) {
if (hasPicture) {
AsyncImage(
model = picture,
contentDescription = "Profile picture",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
modifier = Modifier.fillMaxSize()
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "Pick avatar",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onTertiaryFixed
)
}
}
}
}
}
Surface(
modifier = Modifier
.fillMaxWidth()
.weight(1f, fill = true),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What others should call you?",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = name,
onValueChange = { name = it },
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.tertiaryFixedDim,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
Text(
"Alice",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
enabled = !isBusy,
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
}
Spacer(modifier = Modifier.size(16.dp))
Button(
modifier = Modifier
.fillMaxWidth()
.size(ButtonDefaults.MediumContainerHeight),
onClick = {
val scope = CoroutineScope(Dispatchers.Main)
scope.launch {
val bytes = withContext(Dispatchers.IO) {
(picture as? Uri)?.let {
context.contentResolver.openInputStream(it)?.readBytes()
}
}
val type =
(picture as? Uri)?.let { context.contentResolver.getType(it) }
onConfirm(name, bio, bytes, type)
}
},
enabled = name.isNotBlank() && !isBusy
) {
if (isBusy) {
LoadingIndicator()
} else {
Text(
text = buttonLabel,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
}

View File

@@ -9,25 +9,32 @@ import su.reya.coop.Room
import su.reya.coop.short
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
// Return early if there's a custom subject/room name
subject?.takeIf { it.isNotBlank() }?.let { return flowOf(it) }
val memberFlows = members.map { viewModel.getMetadata(it) }
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
if (displayMembers.isEmpty()) return flowOf("Unknown")
return combine(displayMembers.map { 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()
}
return combine(memberFlows) { metadataArray ->
if (isGroup()) {
val profiles = metadataArray.map { it?.asRecord() }
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
var combined = names.joinToString(", ")
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
combined.ifBlank { "Unknown group" }
val combined = names.joinToString(", ")
val extraCount = members.size - names.size
if (extraCount > 0) "$combined, +$extraCount" else combined
} else {
val profile = metadataArray.firstOrNull()?.asRecord()
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
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 kotlinx.coroutines.flow.flowOf(null)
val firstMember = members.firstOrNull() ?: return flowOf(null)
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="120"
android:viewportHeight="120">
<group
android:translateX="20"
android:translateY="20">
<path
android:pathData="M37.54,74C52.75,74 65.08,61.581 65.08,46.262C65.08,44.87 63.959,43.74 62.576,43.74H12.504C11.12,43.74 10,44.87 10,46.262C10,61.581 22.33,74 37.54,74Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M49.707,14.321L49.493,14.263L49.279,14.207L49.062,14.153L48.843,14.099L48.627,14.049L48.411,14L48.194,13.952L47.976,13.907L47.748,13.861L47.518,13.817L47.287,13.775L47.055,13.735L46.94,13.715L46.828,13.697L46.604,13.662L46.381,13.629L46.157,13.598L45.927,13.568L45.696,13.54L45.467,13.514L45.236,13.49L45.005,13.467L44.774,13.447L44.543,13.428L44.31,13.412L44.08,13.398L43.851,13.385L43.623,13.375L43.392,13.366L43.159,13.359L42.926,13.354L42.694,13.351L42.46,13.35H42.184L41.903,13.356L41.623,13.363L41.343,13.373L41.063,13.385L40.784,13.401L40.504,13.419L40.224,13.44L39.932,13.466L39.64,13.494L39.357,13.524L39.074,13.558L38.781,13.596L38.489,13.637L38.209,13.679L37.929,13.724L37.649,13.772L37.369,13.823L37.103,13.875L36.836,13.929L36.57,13.986L36.418,14.02L36.306,14.045L36.026,14.111L35.746,14.18L35.466,14.252L35.187,14.328L34.917,14.403L34.648,14.482L34.512,14.523L34.376,14.564L34.109,14.649L33.847,14.734L33.587,14.821C33.587,14.821 32.45,13.249 32.536,12.136C32.644,10.731 33.588,9.592 34.837,9.163C34.764,8.837 34.687,8.51 34.713,8.16C34.859,6.274 36.501,4.864 38.377,5.01C39.485,5.096 40.376,5.734 40.932,6.605C41.616,5.83 42.598,5.339 43.705,5.425C45.449,5.561 46.733,7.01 46.794,8.726C47.033,8.691 47.264,8.617 47.516,8.637C49.394,8.783 50.796,10.429 50.65,12.315C50.58,13.232 49.707,14.321 49.707,14.321Z"
android:fillColor="#000000"/>
<path
android:pathData="M14.92,41.088C14.92,25.769 27.25,13.35 42.46,13.35C57.669,13.35 70,25.769 70,41.088C70,42.481 68.879,43.61 67.496,43.61H17.424C16.04,43.61 14.92,42.481 14.92,41.088ZM54.627,26.453C54.476,26.411 54.325,26.389 54.172,26.389C54.018,26.389 53.867,26.411 53.717,26.453C53.566,26.496 53.42,26.561 53.279,26.645C53.138,26.73 53.003,26.833 52.876,26.956C52.748,27.079 52.63,27.218 52.522,27.375C52.413,27.531 52.316,27.701 52.232,27.885C52.147,28.068 52.075,28.262 52.016,28.467C51.957,28.671 51.913,28.882 51.883,29.098C51.854,29.315 51.839,29.533 51.839,29.755C51.839,29.976 51.854,30.195 51.883,30.411C51.913,30.628 51.957,30.838 52.016,31.043C52.075,31.247 52.147,31.441 52.232,31.625C52.316,31.808 52.413,31.979 52.522,32.135C52.63,32.291 52.748,32.43 52.876,32.553C53.003,32.676 53.138,32.78 53.279,32.864C53.42,32.949 53.566,33.013 53.717,33.056C53.867,33.1 54.018,33.121 54.172,33.121C54.325,33.121 54.476,33.1 54.627,33.056C54.777,33.013 54.923,32.949 55.064,32.864C55.206,32.78 55.341,32.676 55.468,32.553C55.595,32.43 55.714,32.291 55.822,32.135C55.93,31.979 56.027,31.808 56.112,31.625C56.197,31.441 56.268,31.247 56.327,31.043C56.386,30.838 56.431,30.628 56.46,30.411C56.49,30.195 56.505,29.976 56.505,29.755C56.505,29.533 56.49,29.315 56.46,29.098C56.431,28.882 56.386,28.671 56.327,28.467C56.268,28.262 56.197,28.068 56.112,27.885C56.027,27.701 55.93,27.531 55.822,27.375C55.714,27.218 55.595,27.079 55.468,26.956C55.341,26.833 55.206,26.73 55.064,26.645C54.923,26.561 54.777,26.496 54.627,26.453Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</group>
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="secondaryContainer">#F8FF37</color>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/secondaryContainer</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/coop</item>
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
</style>
</resources>

View File

@@ -1,23 +1,27 @@
[versions]
agp = "9.2.1"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
android-compileSdk = "37"
android-minSdk = "26"
android-targetSdk = "37"
androidx-activity = "1.13.0"
androidx-appcompat = "1.7.1"
androidx-core = "1.18.0"
androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0"
androidx-navigation = "2.8.8"
androidx-testExt = "1.3.0"
composeMultiplatform = "1.10.3"
androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0"
datastorePreferences = "1.2.1"
junit = "4.13.2"
kotlin = "2.3.20"
kotlinx-serialization = "1.8.0"
material3 = "1.10.0-alpha05"
ktor = "3.4.3"
kotlin = "2.3.21"
kotlinx-serialization = "1.11.0"
material3 = "1.11.0-alpha07"
multiplatform-nav3-ui = "1.1.1"
ktor = "3.5.0"
[libraries]
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
@@ -25,12 +29,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
@@ -43,6 +48,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -25,15 +25,16 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
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(libs.ktor.client.core)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation("su.reya:nostr-sdk-kmp:0.2.6")
implementation("com.squareup.okio:okio:3.16.2")
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)

View File

@@ -1,75 +0,0 @@
package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.url
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readBytes
import io.ktor.websocket.readText
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import rust.nostr.sdk.ConnectionMode
import rust.nostr.sdk.CustomWebSocketTransport
import rust.nostr.sdk.WebSocketAdapter
import rust.nostr.sdk.WebSocketAdapterWrapper
import rust.nostr.sdk.WebSocketMessage
class KtorWebSocketAdapter(
private val client: HttpClient,
private val session: DefaultClientWebSocketSession
) : WebSocketAdapter {
override suspend fun send(msg: WebSocketMessage) {
try {
when (msg) {
is WebSocketMessage.Text -> session.send(Frame.Text(msg.text))
is WebSocketMessage.Binary -> session.send(Frame.Binary(true, msg.bytes))
is WebSocketMessage.Ping -> session.send(Frame.Ping(msg.bytes))
is WebSocketMessage.Pong -> session.send(Frame.Pong(msg.bytes))
else -> {}
}
} catch (e: Exception) {
println("Attempted to send on a closed WebSocket: ${e.message}")
throw e
}
}
override suspend fun recv(): WebSocketMessage? {
return try {
when (val frame = session.incoming.receive()) {
is Frame.Text -> WebSocketMessage.Text(frame.readText())
is Frame.Binary -> WebSocketMessage.Binary(frame.readBytes())
is Frame.Ping -> WebSocketMessage.Ping(frame.readBytes())
is Frame.Pong -> WebSocketMessage.Pong(frame.readBytes())
else -> null
}
} catch (e: ClosedReceiveChannelException) {
null
} catch (e: Exception) {
throw e
}
}
override suspend fun closeConnection() {
session.cancel()
session.close()
}
}
class CoopWebSocketClient(private val httpClient: HttpClient) : CustomWebSocketTransport {
override fun supportPing(): Boolean = false
override suspend fun connect(url: String, mode: ConnectionMode): WebSocketAdapterWrapper {
try {
val session = httpClient.webSocketSession {
url(url)
}
val adapter = KtorWebSocketAdapter(httpClient, session)
return WebSocketAdapterWrapper(adapter)
} catch (e: Exception) {
throw e
}
}
}

View File

@@ -0,0 +1,44 @@
package su.reya.coop
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
/**
* Platform interface for NIP-55 external signer communication.
* Implemented on Android; no-op/null on other platforms.
*/
interface ExternalSignerHandler {
fun isAvailable(): Boolean
fun setPackageName(packageName: String)
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
}
@Serializable
data class SignerPermission(
val type: String,
val kind: Int? = null,
)
object SignerPermissions {
fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
fun toJson(permissions: List<SignerPermission>): String {
return Json.encodeToString(permissions)
}
}
data class ExternalSignerResult(
val pubkey: PublicKey,
val packageName: String,
)

View File

@@ -0,0 +1,40 @@
package su.reya.coop
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.Event
import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.UnsignedEvent
class ExternalSignerProxy(
private val handler: ExternalSignerHandler,
private val currentUser: PublicKey,
) : AsyncNostrSigner {
override suspend fun getPublicKeyAsync(): PublicKey {
return currentUser
}
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
return Event.fromJson(signedJson)
}
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
return handler.nip04Encrypt(content, publicKey)
?: throw Exception("NIP-04 encrypt rejected")
}
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
return handler.nip04Decrypt(encryptedContent, publicKey)
?: throw Exception("NIP-04 decrypt rejected")
}
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
return handler.nip44Encrypt(content, publicKey, currentUser)
?: throw Exception("NIP-44 encrypt rejected")
}
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
return handler.nip44Decrypt(payload, publicKey, currentUser)
?: throw Exception("NIP-44 decrypt rejected")
}
}

View File

@@ -2,17 +2,16 @@ package su.reya.coop
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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
@@ -39,32 +38,42 @@ import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.RelayCapabilities
import rust.nostr.sdk.RelayMessageEnum
import rust.nostr.sdk.RelayMetadata
import rust.nostr.sdk.RelayStatus
import rust.nostr.sdk.RelayUrl
import rust.nostr.sdk.ReqExitPolicy
import rust.nostr.sdk.ReqTarget
import rust.nostr.sdk.SendEventTarget
import rust.nostr.sdk.SignerAuthenticator
import rust.nostr.sdk.SingleLetterTag
import rust.nostr.sdk.SleepWhenIdle
import rust.nostr.sdk.SubscribeAutoCloseOptions
import rust.nostr.sdk.Tag
import rust.nostr.sdk.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 rust.nostr.sdk.nip59MakeGiftWrapAsync
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
object NostrManager {
val instance = Nostr()
val BOOTSTRAP_RELAYS = listOf(
"wss://relay.primal.net",
"wss://purplepag.es"
)
val INDEXER_RELAY = listOf(
"wss://indexer.coracle.social",
)
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
}
class Nostr {
private val _isInitialized = MutableStateFlow(false)
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
var client: Client? = null
private set
var signer: UniversalSigner = UniversalSigner(Keys.generate())
@@ -76,67 +85,109 @@ class Nostr {
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
private set
suspend fun init(dbPath: String) {
private val isInitialized = MutableStateFlow(false)
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _metadataUpdates =
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
val metadataUpdates = _metadataUpdates.asSharedFlow()
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
val contactListUpdates = _contactListUpdates.asSharedFlow()
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event)
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit)
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
_metadataUpdates.emit(pubkey to metadata)
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
_contactListUpdates.emit(contacts)
suspend fun init(
dbPath: String,
logLevel: LogLevel = LogLevel.WARN
) {
try {
if (_isInitialized.value) return
if (isInitialized.value) return
// Initialize the logger for nostr client
initLogger(LogLevel.DEBUG)
initLogger(logLevel)
// Initialize configurations for nostr client
val lmdb = NostrDatabase.lmdb(dbPath)
val gossip = NostrGossip.inMemory()
val authenticator = SignerAuthenticator(signer)
val idleTimeout = Duration.parse("5m")
val httpClient = HttpClient {
install(WebSockets)
}
client =
ClientBuilder()
.signer(signer)
.websocketTransport(CoopWebSocketClient(httpClient))
.authenticator(authenticator)
.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
isInitialized.value = true
} catch (e: Exception) {
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
}
}
suspend fun waitUntilInitialized() {
_isInitialized.first { it }
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"))
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
client?.addRelay(RelayUrl.parse(url))
}
NostrManager.INDEXER_RELAY.forEach { url ->
client?.addRelay(
url = RelayUrl.parse(url),
capabilities = RelayCapabilities.gossip()
)
}
// Connect to all bootstrap relays
client?.connect()
}
// 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 reconnect() {
NostrManager.ALL_RELAYS.forEach { url ->
try {
client?.relay(RelayUrl.parse(url)).let { relay ->
if (relay != null) {
if (relay.status() != RelayStatus.CONNECTED) {
relay.connect()
}
}
}
} catch (e: Exception) {
println("Failed to reconnect relay: ${e.message}")
}
}
}
suspend fun disconnect() {
client?.shutdown()
NostrManager.ALL_RELAYS.forEach { url ->
try {
client?.disconnectRelay(RelayUrl.parse(url))
} catch (e: Exception) {
println("Failed to disconnect relay: ${e.message}")
}
}
}
suspend fun exit() {
@@ -147,8 +198,6 @@ class Nostr {
suspend fun setSigner(new: AsyncNostrSigner) {
try {
signer.switch(new)
// Fetch metadata for current user
getUserMetadata()
} catch (e: Exception) {
throw IllegalStateException("Failed to set signer: ${e.message}", e)
}
@@ -209,77 +258,22 @@ class Nostr {
client?.subscribe(
target = ReqTarget.manual(target),
id = "all-gift-wraps"
id = "gift-wraps"
)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
}
}
suspend fun handleLiteNotifications(
onNewMessage: (UnsignedEvent) -> Unit,
) {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return
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
val subscriptionId = message.subscriptionId
// Ignore events not from the newest gift wraps subscription
if (subscriptionId != "newest-gift-wraps") continue
// Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue
processedEvent.add(event.id())
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
try {
val rumor = extractRumor(event)
// Handle new message
rumor?.createdAt()?.asSecs()?.let {
if (it >= now.asSecs()) {
onNewMessage(rumor)
}
}
} catch (e: Exception) {
println("Failed to extract rumor: $e")
}
}
}
else -> {
/* Ignore other event kinds */
}
}
}
else -> {
/* Ignore other message types */
}
}
}
}
suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit,
onSubscriptionClose: () -> Unit,
) = coroutineScope {
) = supervisorScope {
val now = Timestamp.now()
val processedEvent = mutableSetOf<EventId>()
val notifications = client?.notifications() ?: return@coroutineScope
val notifications = client?.notifications() ?: return@supervisorScope
var eoseTrackerJob: Job? = null
@@ -293,7 +287,6 @@ class Nostr {
when (val message = notification.message.asEnum()) {
is RelayMessageEnum.EventMsg -> {
val event = message.event
val id = message.subscriptionId
// Prevent processing duplicate events
if (processedEvent.contains(event.id())) continue
@@ -330,7 +323,7 @@ class Nostr {
eoseTrackerJob?.cancel()
// Start a new tracker
eoseTrackerJob = launch {
delay(10000) // Wait for 10 seconds
delay(10000.milliseconds) // Wait for 10 seconds
onSubscriptionClose()
}
@@ -349,7 +342,7 @@ class Nostr {
is RelayMessageEnum.EndOfStoredEvents -> {
val subscriptionId = message.subscriptionId
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
if (subscriptionId == "gift-wraps") {
onSubscriptionClose()
}
}
@@ -394,16 +387,15 @@ class Nostr {
val currentUser =
signer.currentUser ?: throw IllegalStateException("User not signed in")
// Ensure the rumor ID is set
val rumor = rumor.ensureId()
// Construct the room id
val roomId = rumor.roomId()
// Construct reference tags
val tags = listOf(
Tag.identifier(giftId.toHex()),
Tag.event(rumor.id()!!),
Tag.reference(roomId.toString()),
Tag.custom(TagKind.Unknown("k"), listOf("dm"))
Tag.custom("a", listOf(roomId.toString())),
Tag.custom("k", listOf("14"))
)
// Set event kind
@@ -411,8 +403,8 @@ class Nostr {
val event = EventBuilder(kind, rumor.asJson())
.tags(tags)
.build(currentUser)
.signWithKeys(Keys.generate())
.finalizeUnsigned(currentUser)
.signAsync(Keys.generate())
client?.database()?.saveEvent(event)
} catch (e: Exception) {
@@ -432,7 +424,6 @@ class Nostr {
// Try to unwrap the gift with each signer
for (signer in signers) {
try {
// TODO: custom unwrapping logic
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
val rumor = gift.rumor()
// Save the rumor to the database
@@ -491,7 +482,7 @@ class Nostr {
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);
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys);
client?.sendEvent(
event = relayListEvent,
@@ -502,7 +493,7 @@ class Nostr {
// Send messaging relay list event
val msgRelayList = getDefaultMsgRelayList()
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
client?.sendEvent(
event = msgRelayListEvent,
@@ -513,7 +504,7 @@ class Nostr {
// Send metadata event
val metadata =
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
client?.sendEvent(
event = metadataEvent,
@@ -523,8 +514,8 @@ class Nostr {
// Send contact list event
val defaultContact =
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
client?.sendEvent(
event = contactListEvent,
@@ -535,20 +526,67 @@ class Nostr {
setSigner(keys)
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: String? = null
): Metadata {
val currentUser = signer.currentUser ?: throw IllegalStateException("User not signed in")
try {
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
val newRecord = record.copy(
displayName = name ?: record.displayName,
about = bio ?: record.about,
picture = picture ?: record.picture
)
val newMetadata = Metadata.fromRecord(newRecord)
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
client?.sendEvent(
event = event,
target = SendEventTarget.broadcast(),
ackPolicy = AckPolicy.none()
)
return newMetadata
} catch (e: Exception) {
throw IllegalStateException("Failed to update identity: ${e.message}", e)
}
}
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
return try {
val kind = Kind.fromStd(KindStandard.METADATA);
val filter = Filter().kind(kind).author(pubkey).limit(1u)
val event = client?.database()?.query(filter)?.first() ?: return null
Metadata.fromJson(event.content())
} catch (e: Exception) {
println("Failed to get latest metadata: ${e.message}")
null
}
}
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
try {
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
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 ->
val metadata = Metadata.fromJson(event.content())
results[event.author()] = metadata
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) {
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
println("Failed to get all cache metadata: ${e.message}")
return emptyMap()
}
}
@@ -568,7 +606,6 @@ class Nostr {
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),
)
)
@@ -581,7 +618,7 @@ class Nostr {
suspend fun setMsgRelays(urls: List<RelayUrl>) {
try {
val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
client?.sendEvent(
event = event,
@@ -631,7 +668,7 @@ class Nostr {
val kTag = SingleLetterTag.lowercase(Alphabet.K)
// Get all events sent by the user
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm")
val filter = Filter().kind(kind).author(userPubkey).customTags(kTag, listOf("14", "dm"))
val events = client?.database()?.query(filter)
// Collect rooms
@@ -676,6 +713,8 @@ class Nostr {
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)
@@ -729,7 +768,7 @@ class Nostr {
}
suspend fun sendMessage(
to: List<PublicKey>,
to: Set<PublicKey>,
content: String,
subject: String? = null,
replies: List<EventId> = emptyList(),
@@ -743,7 +782,7 @@ class Nostr {
// Add a subject tag if provided
if (subject != null) {
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
tags.add(Tag.custom("subject", listOf(subject)))
}
// Add event tags for replies
@@ -755,20 +794,15 @@ class Nostr {
// Add public key tags for each recipient
to.forEach { pubkey ->
if (pubkey != currentUser) {
tags.add(Tag.publicKey(pubkey))
}
tags.add(Tag.publicKey(pubkey))
}
for (receiver in listOf(currentUser) + to) {
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)
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
.tags(tags)
.build(currentUser)
// Ensure the event ID is set
.ensureId()
.finalizeUnsigned(currentUser)
// Emit the rumor to the chat screen
if (receiver == currentUser) {
@@ -776,12 +810,12 @@ class Nostr {
}
// Construct the gift wrap event
val gift = giftWrapAsync(
val gift = nip59MakeGiftWrapAsync(
signer = signer,
receiverPubkey = receiver,
rumor = rumor,
extraTags = listOf(
Tag.custom(TagKind.Unknown("k"), listOf("14"))
Tag.custom("k", listOf("14"))
)
)
@@ -842,13 +876,12 @@ class Nostr {
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 target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
val stream = client?.streamEvents(
target = target,
id = "search",
timeout = Duration.parse("4s"),
timeout = Duration.parse("3s"),
policy = ReqExitPolicy.ExitOnEose
)

View File

@@ -1,12 +1,15 @@
package su.reya.coop
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewModelScope
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -14,13 +17,17 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import rust.nostr.sdk.AsyncNostrSigner
import rust.nostr.sdk.EventBuilder
import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys
import rust.nostr.sdk.Kind
import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri
@@ -32,23 +39,22 @@ 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
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
private val secretStore: SecretStorage,
private val externalSignerHandler: ExternalSignerHandler? = null,
) : ViewModel() {
private val _emptySecret = MutableStateFlow<Boolean?>(null)
val emptySecret = _emptySecret.asStateFlow()
private val _isNotificationBannerDismissed = MutableStateFlow(false)
val isNotificationBannerDismissed = _isNotificationBannerDismissed.asStateFlow()
private val _isCreating = MutableStateFlow(false)
val isCreating = _isCreating.asStateFlow()
private val _signerRequired = MutableStateFlow<Boolean?>(null)
val signerRequired = _signerRequired.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 _isBusy = MutableStateFlow(false)
val isBusy = _isBusy.asStateFlow()
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
@@ -56,10 +62,16 @@ class NostrViewModel(
private val _isRelayListEmpty = MutableStateFlow(false)
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
private val _errorEvents = Channel<String>(Channel.BUFFERED)
@@ -70,16 +82,43 @@ class NostrViewModel(
private val seenPublicKeys = mutableSetOf<PublicKey>()
init {
startNotificationHandler()
startMetadataBatchHandler()
getCacheMetadata()
// Skip the splash screen if a user is already logged in
if (nostr.signer.currentUser != null) {
_signerRequired.value = false
}
// Check if the notification banner has been dismissed
checkNotificationBannerDismissedStatus()
// Check local stored secret (secret key or bunker)
login()
// Automatically reconnect bootstrap relays
reconnect()
// Observe the signer state and verify the relay list
observeSignerAndCheckRelays()
// Get all local stored metadata
getCacheMetadata()
}
fun bindLifecycle(lifecycle: Lifecycle) {
viewModelScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
coroutineScope {
launch { refreshChatRooms() }
launch { runObserver() }
launch { runMetadataBatching() }
}
}
}
}
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
// Disconnect to all bootstrap relays
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
@@ -90,67 +129,110 @@ class NostrViewModel(
private fun showError(message: String) {
viewModelScope.launch {
_errorEvents.send(message)
if (isCreating.value) _isCreating.value = false
}
}
private fun startNotificationHandler() {
private fun checkNotificationBannerDismissedStatus() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.handleNotifications(
onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata)
},
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
if (!_isPartialProcessedGiftWrap.value) {
_isPartialProcessedGiftWrap.value = true
}
},
onNewMessage = { event ->
viewModelScope.launch {
_newEvents.emit(event)
}
},
)
_isNotificationBannerDismissed.value =
secretStore.get("notification_banner_dismissed") == "true"
}
}
private fun startMetadataBatchHandler() {
private fun reconnect() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
nostr.reconnect()
}
}
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
private fun processIncomingEvent(event: UnsignedEvent) {
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
while (true) {
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
if (existingRoom == null) {
nostr.signer.currentUser?.let { user ->
val newRoom = Room.new(event, user)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
}
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout) {
metadataRequestChannel.receive()
private suspend fun runObserver() = coroutineScope {
// Observe new messages
launch {
nostr.newEvents.collect { event ->
val roomId = event.roomId()
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
if (existingRoom == null) {
val currentUser = nostr.signer.currentUser
if (currentUser != null) {
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
if (nextKey != null) {
batch.add(nextKey)
}
_newEvents.emit(event)
}
}
val now = Clock.System.now().toEpochMilliseconds()
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
// Observe contact list updates
launch {
nostr.contactListUpdates.collect { contacts ->
_contactList.value = contacts.toSet()
}
}
nostr.fetchMetadataBatch(keysToRequest)
}
// Observe metadata updates
launch {
nostr.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata)
}
}
// Observes subscription close
launch {
nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
}
private suspend fun runMetadataBatching() = coroutineScope {
// Wait until the client is ready
nostr.waitUntilInitialized()
val batch = mutableSetOf<PublicKey>()
val timeout = 500L // 500ms timeout for batching
while (true) {
val firstKey = metadataRequestChannel.receive()
batch.add(firstKey)
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
while (batch.isNotEmpty()) {
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
metadataRequestChannel.receive()
}
// Only add the key if it's not null
if (nextKey != null) batch.add(nextKey)
// Get current time
val now = Clock.System.now().toEpochMilliseconds()
// Check if the batch is full or timeout has passed
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
val keysToRequest = batch.toList()
batch.clear()
nostr.fetchMetadataBatch(keysToRequest)
}
}
}
@@ -163,7 +245,9 @@ class NostrViewModel(
val results = nostr.getAllCacheMetadata()
results.forEach { (pubkey, metadata) ->
// Update the metadata state
updateMetadata(pubkey, metadata)
// Update seenPublicKeys to avoid duplicate requests
seenPublicKeys.add(pubkey)
}
}
@@ -171,39 +255,28 @@ class NostrViewModel(
private fun login() {
viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
try {
val secret = withTimeoutOrNull(3.seconds) {
secretStore.get("user_signer")
}
// Get user's signer secret
val secret = secretStore.get("user_signer")
// If no secret is found, show onboarding screen
when (secret) {
null -> {
_emptySecret.value = true
if (secret == null) {
_signerRequired.value = true
return@launch
}
else -> _emptySecret.value = false
}
// Handle different signer types
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote)
} catch (e: Exception) {
showError("Error: ${e.message}")
runCatching {
val signer = createSigner(secret)
nostr.setSigner(signer)
}.onSuccess {
_signerRequired.value = false
}.onFailure { e ->
showError("Login failed: ${e.message}")
_signerRequired.value = true
}
} else {
throw IllegalArgumentException("Invalid secret format: $secret")
} catch (e: Exception) {
showError("Login failed: ${e.message}")
_signerRequired.value = true
}
}
}
@@ -214,15 +287,29 @@ class NostrViewModel(
val pubkey = nostr.signer.currentUser
if (pubkey != null) {
delay(3000)
// 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(1000)
delay(500.milliseconds)
}
}
}
@@ -255,7 +342,14 @@ class NostrViewModel(
viewModelScope.launch {
secretStore.clear("user_signer")
nostr.signer.switch(Keys.generate())
_emptySecret.value = true
_signerRequired.value = true
}
}
fun dismissNotificationBanner() {
viewModelScope.launch {
secretStore.set("notification_banner_dismissed", "true")
_isNotificationBannerDismissed.value = true
}
}
@@ -278,106 +372,174 @@ class NostrViewModel(
return keys
}
fun createIdentity(
private suspend fun blossomUpload(file: ByteArray, contentType: String): String? {
try {
// Upload picture to Blossom
val blossom = BlossomClient(
url = "https://blossom.band",
client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
})
}
}
)
val descriptor = blossom.upload(
file = file,
contentType = contentType,
signer = nostr.signer.get()
)
return descriptor?.url
} catch (e: Exception) {
showError("Error: ${e.message}")
return null
}
}
suspend fun updateProfile(
name: String? = null,
bio: String? = null,
picture: ByteArray? = null,
contentType: String? = null
) {
_isBusy.value = true
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
val newMetadata = nostr.updateProfile(name, bio, avatarUrl)
// Update the metadata state after successfully published
updateMetadata(nostr.signer.currentUser!!, newMetadata)
// Update local state
_isBusy.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
try {
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
var avatarUrl = ""
_isBusy.value = true
// Set loading state
_isCreating.value = true
val keys = Keys.generate()
val secret = keys.secretKey().toBech32()
// 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
})
}
}
)
try {
val avatarUrl = picture?.let { blossomUpload(it, contentType ?: "image/jpeg") }
// Create identity
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Persist the secret in the secret storage
secretStore.set("user_signer", secret)
// Update local states
_isBusy.value = false
_signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
val descriptor = blossom.upload(
file = picture,
contentType = contentType,
signer = keys
)
private suspend fun createSigner(secret: String): AsyncNostrSigner {
return when {
secret.startsWith("nsec1") -> Keys.parse(secret)
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
_emptySecret.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
secret.startsWith("bunker://") -> {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = 50.seconds // or Duration.parse("50s")
NostrConnect(uri = bunker, appKeys, timeout, null)
}
secret.startsWith("nip55://") -> {
val handler = externalSignerHandler
?: throw IllegalStateException("External signer not available on this platform")
// Format: nip55://packageName/hexPubkey
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
val packageName = parts[0]
val pubkey = PublicKey.parse(parts[1])
handler.setPackageName(packageName)
ExternalSignerProxy(handler, pubkey)
}
else -> throw IllegalArgumentException("Invalid secret format")
}
}
suspend fun verifyIdentity(secret: String): PublicKey? {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
return keys.publicKey()
} else if (secret.startsWith("bunker://")) {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
// Show toast to ask user to approve the connection
showError("Please approve the connection.")
return remote.getPublicKeyAsync()
} else {
throw IllegalArgumentException("Invalid secret: $secret")
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
}
}
fun importIdentity(secret: String) {
viewModelScope.launch {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
nostr.setSigner(keys)
secretStore.set("user_signer", secret)
// Set an empty secret state
_emptySecret.value = false
} else if (secret.startsWith("bunker://")) {
try {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote =
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote)
secretStore.set("user_signer", secret)
// Set an empty secret state
_emptySecret.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} else {
showError("Please enter a valid Secret or Bunker URI.")
}
suspend fun importIdentity(secret: String) {
_isBusy.value = true
try {
val signer = createSigner(secret)
// Update signer
nostr.setSigner(signer)
// Persist the secret in the secret storage
secretStore.set("user_signer", secret)
// Update local states
_signerRequired.value = false
_isBusy.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
}
suspend fun connectExternalSigner() {
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
_isBusy.value = true
try {
val permissions = SignerPermissions.toJson(
listOf(
SignerPermissions.signEvent(0),
SignerPermissions.signEvent(3),
SignerPermissions.signEvent(10000),
SignerPermissions.signEvent(10050),
SignerPermissions.signEvent(10063),
SignerPermissions.signEvent(22242),
SignerPermissions.signEvent(30030),
SignerPermissions.signEvent(30315),
SignerPermissions.nip44Encrypt(),
SignerPermissions.nip44Decrypt(),
)
)
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
val signer = ExternalSignerProxy(handler, result.pubkey)
// Update signer
nostr.setSigner(signer)
// Store the signer in the secret storage
secretStore.set("user_signer", "nip55://${result.packageName}/${result.pubkey.toHex()}")
// Update local states
_signerRequired.value = false
_isBusy.value = false
} catch (e: Exception) {
throw Exception("Notice: ${e.message}")
}
}
fun isExternalSignerAvailable(): Boolean {
return externalSignerHandler?.isAvailable() == true
}
suspend fun useDefaultMsgRelayList() {
try {
val defaultRelays = nostr.getDefaultMsgRelayList()
@@ -406,40 +568,67 @@ class NostrViewModel(
}
fun createChatRoom(to: List<PublicKey>): Long {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
try {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
// Construct the rumor event
val rumor = EventBuilder
.privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) })
.build(nostr.signer.currentUser!!)
val currentUser = nostr.signer.currentUser!!
// Create a room from the rumor event
val room = Room.new(rumor, nostr.signer.currentUser!!)
_chatRooms.value += room
// Construct the rumor event
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
.tags(to.map { Tag.publicKey(it) })
.finalizeUnsigned(currentUser)
return room.id
// 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 {
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 {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
} catch (e: Exception) {
showError("Error: ${e.message}")
}
val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
}
}
suspend fun refreshChatRooms() {
try {
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
val rooms = nostr.getChatRooms() ?: emptySet()
mergeChatRooms(rooms)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
@@ -456,23 +645,31 @@ class NostrViewModel(
}
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
val room = getChatRoom(roomId)
val members = room.members
try {
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
val members = room.members
return runCatching {
nostr.chatRoomConnect(members.toList())
}.getOrElse { e ->
return runCatching {
nostr.chatRoomConnect(members.toList())
}.getOrElse { e ->
showError("Error: ${e.message}")
members.associateWith { emptyList() }
}
} catch (e: Exception) {
showError("Error: ${e.message}")
members.associateWith { emptyList<RelayUrl>() }
return emptyMap()
}
}
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)
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
nostr.sendMessage(
to = room.members.toList(),
to = room.members,
content = message,
subject = room.subject,
replies = replies,
@@ -499,13 +696,18 @@ class NostrViewModel(
}
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
_chatRooms.value = _chatRooms.value.map { room ->
if (room.id == roomId) {
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt())
} else {
room
}
}.toSet()
_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? {

View File

@@ -6,7 +6,6 @@ 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 kotlin.time.Clock
@@ -37,14 +36,17 @@ data class Room(
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
val id = rumor.roomId()
val createdAt = rumor.createdAt()
val subject = rumor.tags().find(TagKind.Subject)?.content()
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
// Collect the author's public key and all public keys from tags
// Also remove the user's public key from the list, current user is always a member
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
pubkeys.add(rumor.author())
pubkeys.addAll(rumor.tags().publicKeys())
pubkeys.remove(userPubkey)
// Also remove the user's public key from the list, current user is always a member
if (pubkeys.size > 1 && pubkeys.contains(userPubkey)) {
pubkeys.remove(userPubkey)
}
// Create a new Room instance
return Room(

View File

@@ -2,31 +2,42 @@ package su.reya.coop
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
@Volatile
var currentUser: PublicKey? = null
private set
/**
* Get the current signer.
*/
suspend fun get(): AsyncNostrSigner = mutex.withLock {
signer
}
fun get(): AsyncNostrSigner = signer
/**
* Switch to a new signer.
*/
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
val pubkey = try {
withTimeoutOrNull(20.seconds) {
newSigner.getPublicKeyAsync()
}
} catch (e: Exception) {
throw IllegalStateException("Failed to get public key from signer", e)
}
signer = newSigner
currentUser = newSigner.getPublicKeyAsync()
currentUser = pubkey
}
override suspend fun getPublicKeyAsync(): PublicKey? {

View File

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