Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c6426d27 | |||
| 801347ccb1 | |||
| 19daea119d | |||
| 8b2f0faa59 | |||
| a2d28ecc40 | |||
| cd5a393a01 | |||
| 3bfe2308ba | |||
| eed9d401da | |||
| 3943e2baab | |||
| d5ea5570b6 | |||
| 26f9e577f4 | |||
| dd416711e2 | |||
| b90279e174 | |||
| 25ba03562a | |||
| 010a8b4060 | |||
| 750d46bc9e | |||
| 790d24c24b | |||
| 4fbc8446d3 | |||
| b4518ce360 | |||
| 0f4bb71a39 | |||
| 37003e2fc4 | |||
| 154906017e | |||
| 91e4e3b43d | |||
| ea90a43909 | |||
| 938b192136 | |||
| 1f966de654 | |||
| 00821a864b | |||
| 28550f8e25 | |||
| a759ad48e4 | |||
| 0d6b92b0c7 | |||
| 6a69d3a5b2 | |||
| 50b7f7a3f3 | |||
| a65aa70a55 | |||
| 74a37320fe | |||
| b8b3b83952 | |||
| 5c2115e8b7 | |||
| ec337b8756 | |||
| fcae7d5825 | |||
| 1e90b8d4b1 | |||
| 71a8240b1d | |||
| ff383a7c6a | |||
| 15e8c984e2 | |||
| 0da1371345 | |||
| b7c5b64022 | |||
| a3ab489d44 | |||
| b88674d6e2 | |||
| e9eb071208 | |||
| a2a4433a9d | |||
| 1c08525dfc | |||
| 83af44002c | |||
| 44acbfa6b7 | |||
| 2d25cb36bd | |||
| 439cf60b66 | |||
| f1f603525b | |||
| 5c7027e559 | |||
| 4bcb2518b7 | |||
| 25852e08a9 |
31
.github/workflows/android.yml
vendored
@@ -1,25 +1,14 @@
|
|||||||
name: Build and Release
|
name: Build and Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
branches: [ "master" ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
push:
|
||||||
build_type:
|
tags:
|
||||||
description: 'Select build type'
|
- "v*"
|
||||||
required: true
|
|
||||||
default: 'release'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- release
|
|
||||||
- alpha
|
|
||||||
- beta
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
BUILD_TYPE: ${{ github.event.inputs.build_type || 'release' }}
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -34,13 +23,23 @@ jobs:
|
|||||||
- name: Grant execute permission for gradlew
|
- name: Grant execute permission for gradlew
|
||||||
run: chmod +x 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
|
- 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
|
- name: Gitea Release
|
||||||
uses: akkuman/gitea-release-action@v1
|
uses: akkuman/gitea-release-action@v1
|
||||||
with:
|
with:
|
||||||
files: "composeApp/build/outputs/apk/${{ env.BUILD_TYPE }}/*.apk"
|
files: "composeApp/build/outputs/apk/release/*.apk"
|
||||||
server_url: "https://git.reya.su/"
|
server_url: "https://git.reya.su/"
|
||||||
repository: "reya/coop-mobile"
|
repository: "reya/coop-mobile"
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
93
CHANGELOG.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
### [2026-06-20] v0.2.0 (Stable)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Fixed the issue that cause app crashing
|
||||||
|
* Added screener for new message requests
|
||||||
|
* Added request messages screen
|
||||||
|
* Added contact list screen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-06-12] v0.1.9 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Fixed the issue that cause app crashing
|
||||||
|
* Added relay management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-06-09] v0.1.8 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Added support for NIP-55 (use external signer like Amber)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-06-07] v0.1.7 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Added a screen for update profile
|
||||||
|
* Fixed occasional splash screen hang
|
||||||
|
* Improved the battery usage in background
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-06-04] v0.1.6 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Added self chat
|
||||||
|
* Fixed empty chat rooms on refreshes
|
||||||
|
* Fixed crash on startup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-06-03] v0.1.5 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Added crash report screen
|
||||||
|
* Added notification permission banner
|
||||||
|
* Fixed navigation issues after create or import identity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-06-01] v0.1.4 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Added the deep link to allow user open chat screen directly via notification
|
||||||
|
* Fixed the issue cause app crash if room has no member
|
||||||
|
* Improve stability and performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-05-29] v0.1.3 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Added basic notification when the app in background
|
||||||
|
* Redesign the splash screen
|
||||||
|
* Redesign the onboarding screen
|
||||||
|
* Improved UX for the new identity creation and import screen
|
||||||
|
* Fixed some issues cause app crashes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-05-24] v0.1.2 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Fixed the issue that cause app crash when open new chat room via QR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [2026-05-23] v0.1.1 (Pre-Release)
|
||||||
|
|
||||||
|
**What's Changed:**
|
||||||
|
|
||||||
|
* Fixed the issue that cause app crash on startup
|
||||||
BIN
assets/icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/screenshot1.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
assets/screenshot2.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
assets/screenshot3.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
assets/screenshot4.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/screenshot5.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
@@ -1,5 +1,4 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
@@ -20,15 +19,15 @@ kotlin {
|
|||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.8")
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("androidx.datastore:datastore-preferences-core:1.2.1")
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
implementation("io.coil-kt.coil3:coil-compose:3.5.0")
|
||||||
|
implementation("io.coil-kt.coil3:coil-network-okhttp:3.5.0")
|
||||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
|
|
||||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
@@ -40,6 +39,8 @@ kotlin {
|
|||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.datastore)
|
||||||
implementation(projects.shared)
|
implementation(projects.shared)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
@@ -48,23 +49,19 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val localProperties = Properties().apply {
|
|
||||||
val file = rootProject.file("local.properties")
|
|
||||||
if (file.exists()) {
|
|
||||||
load(file.inputStream())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "su.reya.coop"
|
namespace = "su.reya.coop"
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
base.archivesName.set("coop")
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
storeFile = localProperties.getProperty("keystore.path")?.let { file(it) }
|
val path = System.getenv("KEYSTORE_PATH")
|
||||||
storePassword = localProperties.getProperty("keystore.password")
|
storeFile = path?.let { file(it) }
|
||||||
keyAlias = localProperties.getProperty("key.alias")
|
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||||
keyPassword = localProperties.getProperty("key.password")
|
keyAlias = System.getenv("KEY_ALIAS")
|
||||||
|
keyPassword = System.getenv("KEY_PASSWORD")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
@@ -72,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.2.3"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
@@ -84,24 +81,11 @@ android {
|
|||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt")
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
signingConfig = signingConfigs.getByName("release")
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
|||||||
11
composeApp/proguard-rules.pro
vendored
Normal 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
|
||||||
@@ -8,9 +8,17 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="nostrsigner" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -18,19 +26,41 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".CrashActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:process=":crash_handler"
|
||||||
|
android:theme="@android:style/Theme.Material.Light.NoActionBar" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.App.Starting"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="remoteMessaging" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="750dp"
|
android:width="750dp"
|
||||||
android:height="750dp"
|
android:height="750dp"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M440,680L520,680L520,520L680,520L680,440L520,440L520,280L440,280L440,440L280,440L280,520L440,520L440,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M280,720L280,240L360,240L360,720L280,720ZM440,880L440,80L520,80L520,880L440,880ZM120,560L120,400L200,400L200,560L120,560ZM600,720L600,240L680,240L680,720L600,720ZM760,560L760,400L840,400L840,560L760,560Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:autoMirrored="true"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5ZM480,800Q534,800 584,782.5Q634,765 676,732L228,284Q195,326 177.5,376Q160,426 160,480Q160,614 253,707Q346,800 480,800ZM732,676Q765,634 782.5,584Q800,534 800,480Q800,346 707,253Q614,160 480,160Q426,160 376,177.5Q326,195 284,228L732,676ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M424,664L706,382L650,326L424,552L310,438L254,494L424,664ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,471L480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471Q480,471 480,471L480,471ZM480,880Q447,880 423.5,856.5Q400,833 400,800L560,800Q560,833 536.5,856.5Q513,880 480,880ZM160,760L160,680L240,680L240,400Q240,316 290.5,251Q341,186 422,167Q412,189 406.5,213Q401,237 399,262Q364,283 342,319Q320,355 320,400L320,680L640,680L640,558Q660,561 680,561Q700,561 720,558L720,680L800,680L800,760L160,760ZM640,480L628,420Q616,415 605.5,409.5Q595,404 584,396L526,414L486,346L532,306Q530,293 530,280Q530,267 532,254L486,214L526,146L584,164Q595,156 605.5,150.5Q616,145 628,140L640,80L720,80L732,140Q744,145 754.5,150.5Q765,156 776,164L834,146L874,214L828,254Q830,267 830,280Q830,293 828,306L874,346L834,414L776,396Q765,404 754.5,409.5Q744,415 732,420L720,480L640,480ZM736.5,336.5Q760,313 760,280Q760,247 736.5,223.5Q713,200 680,200Q647,200 623.5,223.5Q600,247 600,280Q600,313 623.5,336.5Q647,360 680,360Q713,360 736.5,336.5Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480Q294,480 247,433ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400Q393,400 416.5,376.5ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="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>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM366,650L480,536L594,650L650,594L536,480L650,366L594,310L480,424L366,310L310,366L424,480L310,594L366,650ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerResult
|
||||||
|
|
||||||
|
class AndroidExternalSigner(
|
||||||
|
private val context: Context,
|
||||||
|
private val launcher: ExternalSignerLauncher,
|
||||||
|
) : ExternalSignerHandler {
|
||||||
|
private var cachedPackageName: String? = null
|
||||||
|
|
||||||
|
private data class ContentResolverResult(
|
||||||
|
val result: String,
|
||||||
|
val event: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun queryContentResolver(
|
||||||
|
type: String,
|
||||||
|
payload: String,
|
||||||
|
pubkey: PublicKey? = null,
|
||||||
|
currentUser: PublicKey? = null,
|
||||||
|
): ContentResolverResult? {
|
||||||
|
val uri = "content://$cachedPackageName.${type.uppercase()}".toUri()
|
||||||
|
val projection = mutableListOf<String?>().apply {
|
||||||
|
add(payload)
|
||||||
|
add(pubkey?.toHex() ?: "")
|
||||||
|
add(currentUser?.toHex() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
val cursor = context.contentResolver.query(
|
||||||
|
uri,
|
||||||
|
projection.toTypedArray(),
|
||||||
|
null, null, null,
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
return cursor.use {
|
||||||
|
if (it.getColumnIndex("rejected") > -1) return null
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
val resultIndex = it.getColumnIndex("result")
|
||||||
|
val result = if (resultIndex > -1) it.getString(resultIndex) else null
|
||||||
|
|
||||||
|
val eventIndex = it.getColumnIndex("event")
|
||||||
|
val event = if (eventIndex > -1) it.getString(eventIndex) else null
|
||||||
|
|
||||||
|
ContentResolverResult(result = result!!, event = event)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun request(
|
||||||
|
type: String,
|
||||||
|
payload: String,
|
||||||
|
pubkey: PublicKey? = null,
|
||||||
|
currentUser: PublicKey? = null,
|
||||||
|
resultKey: String = "result",
|
||||||
|
extras: Map<String, String> = emptyMap(),
|
||||||
|
): String? {
|
||||||
|
// Try Content Resolver first
|
||||||
|
queryContentResolver(type, payload, pubkey, currentUser)?.let {
|
||||||
|
return if (resultKey == "event") it.event else it.result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Intent
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:$payload".toUri()).apply {
|
||||||
|
`package` = cachedPackageName
|
||||||
|
putExtra("type", type)
|
||||||
|
if (pubkey != null) putExtra("pubkey", pubkey.toHex())
|
||||||
|
if (currentUser != null) putExtra("current_user", currentUser.toHex())
|
||||||
|
extras.forEach { (k, v) -> putExtra(k, v) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = launcher.launch(intent)
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return null
|
||||||
|
|
||||||
|
val data = result.data ?: return null
|
||||||
|
if (data.getBooleanExtra("rejected", false)) return null
|
||||||
|
|
||||||
|
return data.getStringExtra(resultKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isAvailable(): Boolean {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri())
|
||||||
|
return context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setPackageName(packageName: String) {
|
||||||
|
cachedPackageName = packageName
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getPublicKey(permissions: String?): ExternalSignerResult? {
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, "nostrsigner:".toUri()).apply {
|
||||||
|
putExtra("type", "get_public_key")
|
||||||
|
if (permissions != null) putExtra("permissions", permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = launcher.launch(intent)
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return null
|
||||||
|
|
||||||
|
val data = result.data ?: return null
|
||||||
|
if (data.getBooleanExtra("rejected", false)) return null
|
||||||
|
|
||||||
|
val pubkey = data.getStringExtra("result") ?: return null
|
||||||
|
val packageName = data.getStringExtra("package") ?: return null
|
||||||
|
cachedPackageName = packageName
|
||||||
|
|
||||||
|
return ExternalSignerResult(PublicKey.parse(pubkey), packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String? {
|
||||||
|
val extras = event.id()?.let { mapOf("id" to it.toHex()) } ?: emptyMap()
|
||||||
|
return request(
|
||||||
|
type = "sign_event",
|
||||||
|
payload = event.asJson(),
|
||||||
|
currentUser = currentUser,
|
||||||
|
resultKey = "event",
|
||||||
|
extras = extras,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String? {
|
||||||
|
return request("nip04_encrypt", plaintext, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String? {
|
||||||
|
return request("nip04_decrypt", ciphertext, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44Encrypt(
|
||||||
|
plaintext: String,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
currentUser: PublicKey
|
||||||
|
): String? {
|
||||||
|
return request("nip44_encrypt", plaintext, pubkey, currentUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44Decrypt(
|
||||||
|
ciphertext: String,
|
||||||
|
pubkey: PublicKey,
|
||||||
|
currentUser: PublicKey
|
||||||
|
): String? {
|
||||||
|
return request("nip44_decrypt", ciphertext, pubkey, currentUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,254 +1,249 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.expressiveLightColorScheme
|
import androidx.compose.material3.expressiveLightColorScheme
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.core.util.Consumer
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import androidx.navigation.toRoute
|
import su.reya.coop.repository.ErrorRepository
|
||||||
import kotlinx.coroutines.launch
|
import su.reya.coop.screens.chat.ChatScreen
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
import su.reya.coop.screens.ContactListScreen
|
||||||
import su.reya.coop.screens.ChatScreen
|
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
import su.reya.coop.screens.MyQrScreen
|
import su.reya.coop.screens.MyQrScreen
|
||||||
import su.reya.coop.screens.NewChatScreen
|
import su.reya.coop.screens.NewChatScreen
|
||||||
import su.reya.coop.screens.NewIdentityScreen
|
import su.reya.coop.screens.NewIdentityScreen
|
||||||
import su.reya.coop.screens.OnboardingScreen
|
import su.reya.coop.screens.OnboardingScreen
|
||||||
|
import su.reya.coop.screens.ProfileScreen
|
||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
|
import su.reya.coop.screens.RequestListScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
|
import su.reya.coop.screens.UpdateProfileScreen
|
||||||
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
|
|
||||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||||
error("No NostrViewModel provided")
|
error("No NostrViewModel provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val LocalChatViewModel = staticCompositionLocalOf<ChatViewModel> {
|
||||||
|
error("No ChatViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalAuthViewModel = staticCompositionLocalOf<AuthViewModel> {
|
||||||
|
error("No AuthViewModel provided")
|
||||||
|
}
|
||||||
|
|
||||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
val LocalNavigator = staticCompositionLocalOf<Navigator> {
|
||||||
error("No NavController provided")
|
error("No Navigator provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||||
|
error("No QrScanResult provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(
|
||||||
|
nostrViewModel: NostrViewModel,
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navController = rememberNavController()
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
val darkMode = isSystemInDarkTheme()
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
|
// Get the signer required state
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val signerRequired = authState.signerRequired
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Initialize Nostr View Model and Secret Store
|
// Check if dark theme enabled
|
||||||
val secretStore = remember { SecretStore(context) }
|
val darkMode = isSystemInDarkTheme()
|
||||||
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
|
|
||||||
|
|
||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
// Enable the dynamic color scheme for Android 12+
|
||||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
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()
|
darkMode -> darkColorScheme()
|
||||||
|
// Fallback to the light color scheme
|
||||||
else -> expressiveLightColorScheme()
|
else -> expressiveLightColorScheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = backStack.size > 1) {
|
||||||
|
navigator.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.errorEvents.collect { message ->
|
ErrorRepository.errors.collect { message ->
|
||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activity) {
|
||||||
|
activity?.let {
|
||||||
|
fun handleIntent(intent: Intent) {
|
||||||
|
val screen = Screen.fromIntent(intent)
|
||||||
|
// Prevent pushing the same screen
|
||||||
|
if (screen != null && backStack.lastOrNull() != screen) {
|
||||||
|
navigator.navigate(screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the intent that started the Activity
|
||||||
|
handleIntent(it.intent)
|
||||||
|
|
||||||
|
// Handle new intents while the Activity is running
|
||||||
|
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
|
||||||
|
it.addOnNewIntentListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(backStack.size) {
|
||||||
|
if (backStack.isEmpty()) {
|
||||||
|
(context as? Activity)?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(signerRequired) {
|
||||||
|
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
|
||||||
|
backStack.clear()
|
||||||
|
backStack.add(Screen.Onboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MaterialExpressiveTheme(
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography(),
|
||||||
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNostrViewModel provides viewModel,
|
LocalNostrViewModel provides nostrViewModel,
|
||||||
|
LocalChatViewModel provides chatViewModel,
|
||||||
|
LocalAuthViewModel provides authViewModel,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
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) {
|
NavDisplay(
|
||||||
// Navigate to the home screen if the secret is already set
|
backStack = backStack,
|
||||||
if (emptySecret == false) {
|
onBack = {
|
||||||
navController.navigate(Screen.Home) {
|
if (backStack.size > 1) {
|
||||||
popUpTo(Screen.Onboarding) { inclusive = true }
|
backStack.removeLastOrNull()
|
||||||
}
|
} else {
|
||||||
}
|
(context as? Activity)?.finish()
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading screen while initializing
|
|
||||||
if (emptySecret == null) return@CompositionLocalProvider
|
|
||||||
|
|
||||||
// Show the relay setup dialog if the msg relay list is empty
|
|
||||||
if (isRelayListEmpty) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { viewModel.dismissRelayWarning() },
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight(0.5f)
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Messaging Relays are required",
|
|
||||||
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Coop cannot found your messaging relays. To send and receive messages on Coop, you need to set up at least one messaging relay.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
fontStyle = FontStyle.Italic,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
viewModel.useDefaultMsgRelayList()
|
|
||||||
sheetState.hide()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
entryDecorators = listOf(
|
||||||
.fillMaxWidth()
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
) {
|
),
|
||||||
Text(
|
entryProvider = entryProvider {
|
||||||
text = "Continue",
|
entry<Screen.Home> {
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
HomeScreen()
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
entry<Screen.RequestList> {
|
||||||
|
RequestListScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Onboarding> {
|
||||||
|
OnboardingScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Import> {
|
||||||
|
ImportScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.NewIdentity> {
|
||||||
|
NewIdentityScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Chat> { key ->
|
||||||
|
ChatScreen(id = key.id, screening = key.screening)
|
||||||
|
}
|
||||||
|
entry<Screen.NewChat> {
|
||||||
|
NewChatScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Profile> { key ->
|
||||||
|
ProfileScreen(pubkey = key.pubkey)
|
||||||
|
}
|
||||||
|
entry<Screen.UpdateProfile> {
|
||||||
|
UpdateProfileScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Scan> {
|
||||||
|
ScanScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.MyQr> {
|
||||||
|
MyQrScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.ContactList> {
|
||||||
|
ContactListScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Relay> {
|
||||||
|
RelayScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NavHost(
|
class Navigator(private val backStack: NavBackStack<NavKey>) {
|
||||||
navController = navController,
|
fun navigate(route: NavKey) {
|
||||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
backStack.add(route)
|
||||||
) {
|
|
||||||
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(
|
fun goBack() {
|
||||||
isLoading = isCreating,
|
if (backStack.size > 1) {
|
||||||
onBack = { navController.popBackStack() },
|
backStack.removeAt(backStack.lastIndex)
|
||||||
onSave = { secret ->
|
}
|
||||||
viewModel.importIdentity(secret)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
composable<Screen.NewIdentity> { backStackEntry ->
|
|
||||||
val isCreating by viewModel.isCreating.collectAsState()
|
|
||||||
|
|
||||||
NewIdentityScreen(
|
class QrScanResult {
|
||||||
isLoading = isCreating,
|
var content by mutableStateOf<String?>(null)
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSave = { name, bio, uri ->
|
fun clear() {
|
||||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
content = null
|
||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
composeApp/src/androidMain/kotlin/su/reya/coop/CrashActivity.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
|
class CrashActivity : ComponentActivity() {
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val errorText = intent.getStringExtra("error") ?: "Unknown error"
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialExpressiveTheme {
|
||||||
|
Scaffold(
|
||||||
|
content = { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"App Crashed",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Please copy the log below and send it to the developer.",
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorText,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = {
|
||||||
|
finish();
|
||||||
|
exitProcess(0)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Exit")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val clipboard =
|
||||||
|
getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
val data = ClipData.newPlainText("Crash Log", errorText)
|
||||||
|
clipboard.setPrimaryClip(data)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text("Copy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.ActivityResult
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class ExternalSignerLauncher {
|
||||||
|
private var launcher: ActivityResultLauncher<Intent>? = null
|
||||||
|
private var pendingResult: CompletableDeferred<ActivityResult>? = null
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
fun register(launcher: ActivityResultLauncher<Intent>) {
|
||||||
|
this.launcher = launcher
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun launch(intent: Intent): ActivityResult = mutex.withLock {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val deferred = CompletableDeferred<ActivityResult>()
|
||||||
|
pendingResult = deferred
|
||||||
|
launcher?.launch(intent) ?: throw IllegalStateException("Signer not registered")
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun onResult(result: ActivityResult) {
|
||||||
|
pendingResult?.complete(result)
|
||||||
|
pendingResult = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,108 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Process
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
|
import su.reya.coop.viewmodel.AuthViewModel
|
||||||
|
import su.reya.coop.viewmodel.ChatViewModel
|
||||||
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
companion object {
|
||||||
|
val externalSignerLauncher = ExternalSignerLauncher()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val factory by lazy {
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
private val androidSigner =
|
||||||
|
AndroidExternalSigner(this@MainActivity, externalSignerLauncher)
|
||||||
|
private val secretStore = SecretStore(this@MainActivity)
|
||||||
|
private val nostrViewModel =
|
||||||
|
NostrViewModel(NostrManager.instance)
|
||||||
|
private val chatViewModel =
|
||||||
|
ChatViewModel(NostrManager.instance)
|
||||||
|
private val authViewModel =
|
||||||
|
AuthViewModel(NostrManager.instance, secretStore, androidSigner)
|
||||||
|
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return when {
|
||||||
|
modelClass.isAssignableFrom(NostrViewModel::class.java) -> nostrViewModel
|
||||||
|
modelClass.isAssignableFrom(ChatViewModel::class.java) -> chatViewModel
|
||||||
|
modelClass.isAssignableFrom(AuthViewModel::class.java) -> authViewModel
|
||||||
|
else -> throw IllegalArgumentException("Unknown ViewModel class")
|
||||||
|
} as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val nostrViewModel: NostrViewModel by viewModels { factory }
|
||||||
|
private val chatViewModel: ChatViewModel by viewModels { factory }
|
||||||
|
private val authViewModel: AuthViewModel by viewModels { factory }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val intent = Intent(this, NostrForegroundService::class.java)
|
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||||
|
startForegroundService(serviceIntent)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
// Keep the splash screen visible until the signer check is complete
|
||||||
startForegroundService(intent)
|
splashScreen.setKeepOnScreenCondition {
|
||||||
} else {
|
authViewModel.state.value.signerRequired == null
|
||||||
startService(intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(
|
||||||
|
nostrViewModel = nostrViewModel,
|
||||||
|
chatViewModel = chatViewModel,
|
||||||
|
authViewModel = authViewModel,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,42 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
sealed interface Screen {
|
sealed interface Screen : NavKey {
|
||||||
|
companion object {
|
||||||
|
fun fromIntent(intent: Intent): Screen? {
|
||||||
|
val data = intent.data ?: return null
|
||||||
|
if (data.scheme != "coop") return null
|
||||||
|
|
||||||
|
return when (data.host) {
|
||||||
|
// Matches coop://chat/{id}
|
||||||
|
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it, false) }
|
||||||
|
// Matches coop://profile/{pubkey}
|
||||||
|
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Chat(val id: Long) : Screen
|
data object RequestList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Chat(val id: Long, val screening: Boolean = false) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Profile(val pubkey: String) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object ContactList : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object UpdateProfile : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object NewChat : Screen
|
data object NewChat : Screen
|
||||||
|
|||||||
@@ -3,85 +3,183 @@ package su.reya.coop
|
|||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.annotation.RequiresApi
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.nostr.NostrManager
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
private const val GROUP_KEY_MESSAGES = "su.reya.coop.MESSAGES"
|
||||||
|
|
||||||
class NostrForegroundService : Service() {
|
class NostrForegroundService : Service() {
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private val nostr = NostrManager.instance
|
private val nostr by lazy { NostrManager.instance }
|
||||||
|
private var notificationJob: Job? = null
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
private fun isUserInApp(): Boolean {
|
override fun onCreate() {
|
||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
super.onCreate()
|
||||||
|
createNotificationChannel()
|
||||||
|
startAsForeground()
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
createNotificationChannel()
|
if (intent?.action == "STOP_SERVICE") {
|
||||||
val notification = createNotification("Connecting to Nostr...")
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
startForeground(1, notification)
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
serviceScope.launch {
|
// Start the nostr service in the foreground
|
||||||
|
startAsForeground()
|
||||||
|
|
||||||
|
// Check if the service is already running
|
||||||
|
if (notificationJob?.isActive == true) return START_STICKY
|
||||||
|
|
||||||
|
// Start the Nostr client
|
||||||
|
notificationJob = serviceScope.launch {
|
||||||
try {
|
try {
|
||||||
|
Log.d("Coop", "Starting Nostr in background")
|
||||||
|
|
||||||
|
// Create a database directory
|
||||||
val dbDir = File(filesDir, "nostr")
|
val dbDir = File(filesDir, "nostr")
|
||||||
dbDir.mkdirs()
|
dbDir.mkdirs()
|
||||||
|
|
||||||
// Initialize Nostr client
|
// Initialize Nostr client
|
||||||
|
try {
|
||||||
nostr.init(dbDir.absolutePath)
|
nostr.init(dbDir.absolutePath)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to initialize Nostr Client", e)
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
nostr.connectBootstrapRelays()
|
nostr.connectBootstrapRelays()
|
||||||
|
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
nostr.handleLiteNotifications { event ->
|
nostr.handleNotifications(
|
||||||
|
onMetadataUpdate = { pubkey, metadata ->
|
||||||
|
serviceScope.launch { nostr.profiles.emitMetadataUpdate(pubkey, metadata) }
|
||||||
|
},
|
||||||
|
onContactListUpdate = { contacts ->
|
||||||
|
serviceScope.launch { nostr.profiles.emitContactListUpdate(contacts) }
|
||||||
|
},
|
||||||
|
onNewMessage = { event ->
|
||||||
|
serviceScope.launch {
|
||||||
if (!isUserInApp()) {
|
if (!isUserInApp()) {
|
||||||
showNewMessageNotification(event.content())
|
showNewMessageNotification(event.roomId(), event.content())
|
||||||
|
}
|
||||||
|
nostr.emitNewEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to start Nostr in background: ${e.message}")
|
Log.e("Coop", "Failed to start Nostr", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
private fun isUserInApp(): Boolean {
|
||||||
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAsForeground() {
|
||||||
|
val notification = createNotification()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
|
||||||
|
} else {
|
||||||
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStopServicePendingIntent(): PendingIntent {
|
||||||
|
val intent = Intent(this, NostrForegroundService::class.java).apply {
|
||||||
|
action = "STOP_SERVICE"
|
||||||
|
}
|
||||||
|
return PendingIntent.getService(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val channel = NotificationChannel(
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
"nostr_service",
|
"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
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
)
|
)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
manager?.createNotificationChannel(messageChannel)
|
||||||
manager?.createNotificationChannel(channel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(content: String): Notification {
|
private fun createNotification(content: String? = null): Notification {
|
||||||
return NotificationCompat.Builder(this, "nostr_service")
|
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||||
.setContentTitle("Coop")
|
.setGroup(GROUP_KEY_MESSAGES)
|
||||||
.setContentText(content)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_send)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(R.drawable.ic_notification, "Stop", getStopServicePendingIntent())
|
||||||
|
|
||||||
|
if (content != null) {
|
||||||
|
builder.setContentTitle("Coop")
|
||||||
|
builder.setContentText(content)
|
||||||
|
} else {
|
||||||
|
builder.setContentTitle("Coop is active")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showNewMessageNotification(message: String) {
|
return builder.build()
|
||||||
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)
|
.setContentText(message)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,349 +0,0 @@
|
|||||||
package su.reya.coop.screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextField
|
|
||||||
import androidx.compose.material3.TextFieldDefaults
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coop.composeapp.generated.resources.Res
|
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
|
||||||
import coop.composeapp.generated.resources.ic_send
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import org.jetbrains.compose.resources.painterResource
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.LocalNostrViewModel
|
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
|
||||||
import su.reya.coop.formatAsGroupHeader
|
|
||||||
import su.reya.coop.roomId
|
|
||||||
import su.reya.coop.shared.Avatar
|
|
||||||
import su.reya.coop.shared.displayNameFlow
|
|
||||||
import su.reya.coop.shared.pictureFlow
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatScreen(
|
|
||||||
id: Long,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
val viewModel = LocalNostrViewModel.current
|
|
||||||
|
|
||||||
val room = viewModel.getChatRoom(id)
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
|
|
||||||
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) }
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Get messages
|
|
||||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
|
||||||
messages.clear()
|
|
||||||
messages.addAll(initialMessages)
|
|
||||||
|
|
||||||
// Get msg relays for each member
|
|
||||||
val results = viewModel.chatRoomConnect(id)
|
|
||||||
results.forEach { (member, relays) ->
|
|
||||||
if (relays.isNotEmpty()) {
|
|
||||||
val metadata = viewModel.getMetadata(member).first { it != null }
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val name = profile?.displayName ?: profile?.name ?: member.short()
|
|
||||||
|
|
||||||
snackbarHostState.showSnackbar("Connected to messaging relays for $name")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop loading spinner
|
|
||||||
setLoading(false)
|
|
||||||
|
|
||||||
// Handle new messages
|
|
||||||
viewModel.newEvents.collect { event ->
|
|
||||||
if (event.roomId() == id) {
|
|
||||||
if (event.id() !in messages.map { it.id() }) {
|
|
||||||
messages.add(0, event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(messages.size) {
|
|
||||||
if (messages.isNotEmpty()) {
|
|
||||||
listState.animateScrollToItem(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
if (loading) {
|
|
||||||
LoadingIndicator(
|
|
||||||
modifier = Modifier.size(32.dp),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Avatar(
|
|
||||||
picture = picture,
|
|
||||||
description = displayName,
|
|
||||||
size = 32.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(
|
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
|
||||||
contentDescription = "Back"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
content = { innerPadding ->
|
|
||||||
Surface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
|
||||||
color = MaterialTheme.colorScheme.surface,
|
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
||||||
) {
|
|
||||||
if (messages.isNotEmpty()) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
reverseLayout = true,
|
|
||||||
state = listState,
|
|
||||||
) {
|
|
||||||
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
|
|
||||||
items(
|
|
||||||
messagesInGroup,
|
|
||||||
key = { it.id()?.toBech32()!! }) { event ->
|
|
||||||
ChatMessage(event)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
DateSeparator(dateHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
|
||||||
text = "No messages yet",
|
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Your conversations will appear here.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ChatInput(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
onSend = {
|
|
||||||
viewModel.sendMessage(id, text)
|
|
||||||
text = ""
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DateSeparator(date: String) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = date,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatMessage(
|
|
||||||
rumor: UnsignedEvent
|
|
||||||
) {
|
|
||||||
val viewModel = LocalNostrViewModel.current
|
|
||||||
val currentUser = viewModel.currentUser()
|
|
||||||
val isMine = rumor.author() == currentUser
|
|
||||||
|
|
||||||
val bubbleShape = if (isMine) {
|
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp)
|
|
||||||
} else {
|
|
||||||
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp)
|
|
||||||
}
|
|
||||||
|
|
||||||
val containerColor =
|
|
||||||
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer
|
|
||||||
|
|
||||||
val contentColor =
|
|
||||||
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
shape = bubbleShape,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 280.dp)
|
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
val id = rumor.id()
|
|
||||||
if (id != null) {
|
|
||||||
val sent = viewModel.isMessageSent(id)
|
|
||||||
println("Sent: $sent")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = rumor.content(),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChatInput(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
onSend: () -> Unit
|
|
||||||
) {
|
|
||||||
|
|
||||||
Surface(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
.height(IntrinsicSize.Min),
|
|
||||||
verticalAlignment = Alignment.Bottom
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
placeholder = { Text("Message") },
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
colors = TextFieldDefaults.colors(
|
|
||||||
focusedIndicatorColor = Color.Transparent,
|
|
||||||
unfocusedIndicatorColor = Color.Transparent
|
|
||||||
),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = onSend,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.aspectRatio(1f),
|
|
||||||
colors = IconButtonDefaults.filledTonalIconButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(Res.drawable.ic_send),
|
|
||||||
contentDescription = "Send"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_check
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListScreen() {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var openAddContactDialog by remember { mutableStateOf(false) }
|
||||||
|
var contactToDelete by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "My Contacts",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
|
contentDescription = "Scanner"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Contact") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddContactDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Contact"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Contact") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
if (contactList.isNotEmpty()) {
|
||||||
|
val contacts = remember(contactList) { contactList.toList() }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
|
) {
|
||||||
|
itemsIndexed(contacts) { index, pubkey ->
|
||||||
|
ContactListItem(
|
||||||
|
pubkey = pubkey,
|
||||||
|
index = index,
|
||||||
|
total = contacts.size,
|
||||||
|
onClick = {
|
||||||
|
val room = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
|
navigator.navigate(Screen.Chat(room))
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
contactToDelete = pubkey
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No contacts yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your contacts will appear here",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (openAddContactDialog) {
|
||||||
|
AddContactDialog(onDismissRequest = { openAddContactDialog = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contactToDelete != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { contactToDelete = null },
|
||||||
|
title = { Text("Delete Contact") },
|
||||||
|
text = { Text("Are you sure you want to remove this contact?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
contactToDelete?.let { nostrViewModel.removeContact(it) }
|
||||||
|
contactToDelete = null
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Delete")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { contactToDelete = null }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddContactDialog(onDismissRequest: () -> Unit) {
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
var contact by remember { mutableStateOf("") }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Contact",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onDismissRequest() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val success = nostrViewModel.addContact(contact)
|
||||||
|
if (success) onDismissRequest()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = contact,
|
||||||
|
onValueChange = { contact = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = contact.isNotEmpty() && !verifyContact(contact)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Contact Address") },
|
||||||
|
placeholder = { Text(text = "npub1... or user@example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Contact address is invalid")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add contact you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListItem(
|
||||||
|
pubkey: PublicKey,
|
||||||
|
index: Int,
|
||||||
|
total: Int = 0,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
|
index = index,
|
||||||
|
count = total
|
||||||
|
),
|
||||||
|
leadingContent = {
|
||||||
|
Avatar(
|
||||||
|
picture = profile?.picture,
|
||||||
|
description = profile?.name,
|
||||||
|
size = 36.dp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = { Text(pubkey.short()) },
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = profile?.name ?: "No name",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyContact(address: String): Boolean {
|
||||||
|
return try {
|
||||||
|
if (address.contains("@")) Nip05Address.parse(address)
|
||||||
|
else PublicKey.parse(address)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse contact: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
package su.reya.coop.screens
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -35,6 +46,7 @@ import androidx.compose.material3.SegmentedListItem
|
|||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
import androidx.compose.material3.TooltipBox
|
import androidx.compose.material3.TooltipBox
|
||||||
import androidx.compose.material3.TooltipDefaults
|
import androidx.compose.material3.TooltipDefaults
|
||||||
@@ -48,7 +60,6 @@ import androidx.compose.material3.rememberTooltipState
|
|||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -58,74 +69,110 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
import coop.composeapp.generated.resources.ic_new_chat
|
import coop.composeapp.generated.resources.ic_new_chat
|
||||||
import coop.composeapp.generated.resources.ic_qr
|
import coop.composeapp.generated.resources.ic_qr
|
||||||
|
import coop.composeapp.generated.resources.ic_request
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalAuthViewModel
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.ago
|
import su.reya.coop.ago
|
||||||
|
import su.reya.coop.rememberUiState
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.shared.displayNameFlow
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.shared.pictureFlow
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen() {
|
||||||
onOpenChat: (Long) -> Unit,
|
val context = LocalContext.current
|
||||||
onNewChat: () -> Unit,
|
val navigator = LocalNavigator.current
|
||||||
) {
|
val qrScanResult = LocalScanResult.current
|
||||||
val clipboard = LocalClipboard.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val clipboardManager = LocalClipboard.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val chatViewModel = LocalChatViewModel.current
|
||||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
|
||||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
|
||||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState()
|
val sheetState = rememberModalBottomSheetState(true)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
val userProfile by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val isRelayListEmpty by nostrViewModel.isRelayListEmpty.collectAsStateWithLifecycle()
|
||||||
|
val isSyncing by chatViewModel.isSyncing.collectAsStateWithLifecycle()
|
||||||
|
val isPartialProcessedGiftWrap by chatViewModel.isPartialProcessedGiftWrap.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBannerDismissed = authState.isNotificationBannerDismissed
|
||||||
|
|
||||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing by remember { mutableStateOf(false) }
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
var isBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
var isNotificationEnabled by remember {
|
||||||
val qrResult by savedStateHandle
|
mutableStateOf(NotificationManagerCompat.from(context).areNotificationsEnabled())
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
}
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { _ ->
|
||||||
|
// State will be updated by LifecycleResumeEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition chat rooms into requests and ongoing
|
||||||
|
val (requests, ongoing) = remember(chatRooms) {
|
||||||
|
chatRooms.partition { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
|
LifecycleResumeEffect(context) {
|
||||||
|
isNotificationEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.getChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey ->
|
.onSuccess { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
try {
|
||||||
navController.navigate(Screen.Chat(roomId))
|
val roomId = chatViewModel.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}") }
|
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +185,22 @@ fun HomeScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
title = {
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Coop",
|
text = "Coop",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
)
|
)
|
||||||
|
if (isSyncing) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// QR Scanner
|
// QR Scanner
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -154,8 +209,8 @@ fun HomeScreen(
|
|||||||
// User
|
// User
|
||||||
IconButton(onClick = { showBottomSheet = true }) {
|
IconButton(onClick = { showBottomSheet = true }) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
size = 32.dp,
|
size = 32.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -176,7 +231,7 @@ fun HomeScreen(
|
|||||||
state = rememberTooltipState(),
|
state = rememberTooltipState(),
|
||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewChat,
|
onClick = { navigator.navigate(Screen.NewChat) },
|
||||||
expanded = expandedFab,
|
expanded = expandedFab,
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -189,10 +244,73 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
if (!isNotificationEnabled && !isBannerDismissed) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Get message notifications",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryFixed,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Make sure you know when you have new messages.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = { authViewModel.dismissNotificationBanner() },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(text = "Maybe later")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
} else {
|
||||||
|
// For older versions, navigate the user directly to App Notification Settings
|
||||||
|
val intent =
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(
|
||||||
|
Settings.EXTRA_APP_PACKAGE,
|
||||||
|
context.packageName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text(text = "Turn on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
@@ -203,7 +321,7 @@ fun HomeScreen(
|
|||||||
onRefresh = {
|
onRefresh = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
viewModel.refreshChatRooms()
|
chatViewModel.refreshChatRooms()
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -250,29 +368,29 @@ fun HomeScreen(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
if (requests.isNotEmpty()) {
|
||||||
|
item { NewRequests(requests) }
|
||||||
|
}
|
||||||
|
|
||||||
|
items(ongoing, key = { it.id }) { room ->
|
||||||
ChatRoom(
|
ChatRoom(
|
||||||
room = room,
|
room = room,
|
||||||
onClick = { onOpenChat(room.id) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showBottomSheet = false },
|
onDismissRequest = { showBottomSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
val pubkey = viewModel.currentUser()
|
|
||||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
|
||||||
|
|
||||||
val userName =
|
|
||||||
userProfile?.asRecord()?.displayName
|
|
||||||
?: userProfile?.asRecord()?.name
|
|
||||||
?: "No name"
|
|
||||||
|
|
||||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sheetState.hide()
|
sheetState.hide()
|
||||||
@@ -297,8 +415,8 @@ fun HomeScreen(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = userProfile?.asRecord()?.picture,
|
picture = userProfile?.picture,
|
||||||
description = userProfile?.asRecord()?.displayName,
|
description = userProfile?.name ?: "No name",
|
||||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
@@ -308,7 +426,7 @@ fun HomeScreen(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = userName,
|
text = userProfile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -318,18 +436,20 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
dismissAndRun { navController.navigate(Screen.MyQr) }
|
scope.launch {
|
||||||
|
userProfile?.publicKey?.let {
|
||||||
|
val bech32 = it.toBech32()
|
||||||
|
val data = ClipData.newPlainText(bech32, bech32)
|
||||||
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = shortPubkey)
|
Text(text = userProfile?.shortPublicKey ?: "Unknown")
|
||||||
}
|
}
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
dismissAndRun { navigator.navigate(Screen.MyQr) }
|
||||||
sheetState.hide()
|
|
||||||
showBottomSheet = false
|
|
||||||
navController.navigate(Screen.MyQr)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
shape = MaterialShapes.Square.toShape()
|
shape = MaterialShapes.Square.toShape()
|
||||||
) {
|
) {
|
||||||
@@ -345,22 +465,245 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
|
if (isRelayListEmpty) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { nostrViewModel.dismissRelayWarning() },
|
||||||
|
sheetState = sheetState,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(0.5f)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Messaging Relays are missing",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
shape = MaterialShapes.Circle.toShape(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "X",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Other people won't be able to send you messages.",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
shape = MaterialShapes.Circle.toShape(),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "X",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "You cannot store your messages.",
|
||||||
|
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Please click the button below to continue with the default set of relays. You can always change them later in the settings.",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "If you believe this is a mistake, please click the Retry button to check again.",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (isBusy) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
enabled = !isBusy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
isBusy = true
|
||||||
|
try {
|
||||||
|
nostrViewModel.refetchMsgRelays()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Failed to refresh metadata: ${e.message}")
|
||||||
|
}
|
||||||
|
isBusy = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Retry",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
enabled = !isBusy,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
nostrViewModel.useDefaultMsgRelayList()
|
||||||
|
sheetState.hide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Use Default",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewRequests(requests: List<Room>) {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val total = requests.size
|
||||||
|
val firstRoom = requests.getOrNull(0)
|
||||||
|
val secondRoom = requests.getOrNull(1)
|
||||||
|
|
||||||
|
val firstRoomState by (firstRoom as Room).rememberUiState(nostrViewModel)
|
||||||
|
val secondRoomState by (secondRoom as Room).rememberUiState(nostrViewModel)
|
||||||
|
|
||||||
|
val supportingText = when {
|
||||||
|
total == 1 -> {
|
||||||
|
val message = firstRoom.lastMessage ?: ""
|
||||||
|
"${firstRoomState.name}: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
total == 2 -> {
|
||||||
|
"${firstRoomState.name} and ${secondRoomState.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
total > 2 -> {
|
||||||
|
val othersCount = total - 2
|
||||||
|
val othersText = if (othersCount == 1) "1 other" else "$othersCount others"
|
||||||
|
"${firstRoomState.name}, ${secondRoomState.name} and $othersText"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem(
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
navigator.navigate(Screen.RequestList)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(MaterialShapes.Clover4Leaf.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_request),
|
||||||
|
contentDescription = "Requests",
|
||||||
|
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = "Requests",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
if (supportingText.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = supportingText,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ListItemDefaults.colors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val roomState by room.rememberUiState(nostrViewModel)
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
modifier = Modifier.clickable(onClick = onClick),
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(picture = picture, description = displayName)
|
Avatar(picture = roomState.picture, description = roomState.picture)
|
||||||
},
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Row(
|
Row(
|
||||||
@@ -368,7 +711,7 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = roomState.name,
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
@@ -382,8 +725,10 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
supportingContent = {
|
supportingContent = {
|
||||||
if (!room.lastMessage.isNullOrBlank()) {
|
if (!room.lastMessage.isNullOrBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = room.lastMessage!!,
|
text = room.lastMessage ?: "",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -398,13 +743,15 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
fun BottomMenuList(
|
fun BottomMenuList(
|
||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Relay Management" to { navController.navigate(Screen.Relay) },
|
"Update Profile" to { navigator.navigate(Screen.UpdateProfile) },
|
||||||
"Spams & Blocks" to { },
|
"Contact List" to { navigator.navigate(Screen.ContactList) },
|
||||||
"Contacts" to { },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Settings" to { }
|
"Settings" to { }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -429,7 +776,14 @@ fun BottomMenuList(
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = { viewModel.logout() },
|
onClick = {
|
||||||
|
onDismiss {
|
||||||
|
authViewModel.logout(onLogout = {
|
||||||
|
nostrViewModel.resetInternalState()
|
||||||
|
chatViewModel.resetInternalState()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
colors = ButtonDefaults.filledTonalButtonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.error,
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
contentColor = MaterialTheme.colorScheme.onError
|
contentColor = MaterialTheme.colorScheme.onError
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -30,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -40,74 +42,69 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_scanner
|
import coop.composeapp.generated.resources.ic_scanner
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalAuthViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen() {
|
||||||
isLoading: Boolean,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSave: (secret: String) -> Unit
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val qrScanResult = LocalScanResult.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val isBusy = authState.isBusy
|
||||||
var secret by remember { mutableStateOf("") }
|
var secret by remember { mutableStateOf("") }
|
||||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||||
val metadata by remember(pubkey) {
|
|
||||||
if (pubkey != null) {
|
|
||||||
viewModel.getMetadata(pubkey!!)
|
|
||||||
} else {
|
|
||||||
MutableStateFlow(null)
|
|
||||||
}
|
|
||||||
}.collectAsState(null)
|
|
||||||
|
|
||||||
|
val profile by remember(pubkey) {
|
||||||
|
pubkey?.let(nostrViewModel::getMetadata) ?: flowOf(null)
|
||||||
|
}.collectAsStateWithLifecycle(null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
LaunchedEffect(qrScanResult.content) {
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
qrScanResult.content?.let { result ->
|
||||||
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 ->
|
|
||||||
runCatching {
|
runCatching {
|
||||||
if (result.startsWith("nsec")) {
|
if (result.startsWith("nsec1")) {
|
||||||
Keys.parse(result)
|
Keys.parse(result)
|
||||||
} else if (result.startsWith("bunker://")) {
|
} else if (result.startsWith("bunker://")) {
|
||||||
NostrConnectUri.parse(result)
|
NostrConnectUri.parse(result)
|
||||||
} else {
|
} else {
|
||||||
throw IllegalArgumentException("Invalid secret: $result")
|
throw IllegalArgumentException("Unsupported secret format")
|
||||||
}
|
}
|
||||||
|
}.onSuccess {
|
||||||
|
secret = result
|
||||||
|
}.onFailure { e ->
|
||||||
|
snackbarHostState.showSnackbar("Invalid secret: ${e.message}")
|
||||||
}
|
}
|
||||||
.onSuccess { it -> secret = result }
|
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +123,7 @@ fun ImportScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -134,7 +131,7 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -145,40 +142,44 @@ fun ImportScreen(
|
|||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
.weight(1f),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(120.dp)
|
.size(120.dp)
|
||||||
.clip(MaterialShapes.Pentagon.toShape()),
|
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = "Profile picture",
|
description = "Profile picture",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = MaterialShapes.Pentagon.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.weight(1f, fill = false),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
@@ -186,6 +187,10 @@ fun ImportScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
@@ -198,14 +203,23 @@ fun ImportScreen(
|
|||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = secret,
|
value = secret,
|
||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
|
enabled = !isBusy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
visualTransformation = PasswordVisualTransformation('*'),
|
visualTransformation = PasswordVisualTransformation('*'),
|
||||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
),
|
),
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
if (secret.isEmpty()) {
|
if (secret.isEmpty()) {
|
||||||
@@ -223,27 +237,32 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (pubkey == null) {
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
if (pubkey == null) {
|
||||||
}
|
authViewModel.verifyIdentity(secret).let { pubkey = it }
|
||||||
} else {
|
} else {
|
||||||
onSave(secret)
|
// Import the identity
|
||||||
|
authViewModel.importIdentity(secret)
|
||||||
|
// Navigate to the home screen
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(ButtonDefaults.MediumContainerHeight),
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
enabled = secret.isNotBlank() && !isLoading,
|
enabled = secret.isNotBlank() && !isBusy,
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
if (isBusy) {
|
||||||
LoadingIndicator()
|
LoadingIndicator()
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = if (pubkey == null) "Verify" else "Continue",
|
text = if (pubkey == null) "Verify" else "Click again to Continue",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,22 +13,24 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen(
|
fun MyQrScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -41,7 +43,7 @@ fun MyQrScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -59,7 +61,7 @@ fun MyQrScreen(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(
|
Image(
|
||||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
painter = rememberQrCodePainter(currentUser?.publicKey?.toBech32() ?: ""),
|
||||||
contentDescription = "My QR"
|
contentDescription = "My QR"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_arrow_next
|
import coop.composeapp.generated.resources.ic_arrow_next
|
||||||
@@ -54,37 +55,35 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NewChatScreen(
|
fun NewChatScreen() {
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val qrScanResult = LocalScanResult.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
|
val contactList by nostrViewModel.contactList.collectAsStateWithLifecycle()
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
|
||||||
val createGroup = remember { mutableStateOf(false) }
|
val createGroup = remember { mutableStateOf(false) }
|
||||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
|
||||||
val qrResult by savedStateHandle
|
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
delay(500) // 500ms debounce
|
delay(500.milliseconds)
|
||||||
|
|
||||||
if (query.startsWith("npub1")) {
|
if (query.startsWith("npub1")) {
|
||||||
val pubkey = try {
|
val pubkey = try {
|
||||||
@@ -97,12 +96,12 @@ fun NewChatScreen(
|
|||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else if (query.contains("@")) {
|
} else if (query.contains("@")) {
|
||||||
val pubkey = viewModel.searchByAddress(query)
|
val pubkey = nostrViewModel.searchByAddress(query)
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
selectedReceivers.add(pubkey)
|
selectedReceivers.add(pubkey)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val results = viewModel.searchByNostr(query)
|
val results = nostrViewModel.searchByNostr(query)
|
||||||
searchResults.clear()
|
searchResults.clear()
|
||||||
searchResults.addAll(results)
|
searchResults.addAll(results)
|
||||||
}
|
}
|
||||||
@@ -111,13 +110,18 @@ fun NewChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching { PublicKey.parse(result) }
|
// Verify the content
|
||||||
.onSuccess { pubkey -> selectedReceivers.add(pubkey) }
|
runCatching {
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
PublicKey.parse(result)
|
||||||
|
}.onSuccess { pubkey ->
|
||||||
|
selectedReceivers.add(pubkey)
|
||||||
|
}.onFailure { e ->
|
||||||
|
snackbarHostState.showSnackbar("Invalid address: ${e.message}")
|
||||||
|
}
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +140,7 @@ fun NewChatScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -144,7 +148,7 @@ fun NewChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -167,8 +171,8 @@ fun NewChatScreen(
|
|||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
val roomId = chatViewModel.createChatRoom(selectedReceivers.toList())
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
expanded = false,
|
expanded = false,
|
||||||
icon = {
|
icon = {
|
||||||
@@ -258,8 +262,8 @@ fun NewChatScreen(
|
|||||||
items = searchResults,
|
items = searchResults,
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
@@ -269,8 +273,8 @@ fun NewChatScreen(
|
|||||||
items = contactList.toList(),
|
items = contactList.toList(),
|
||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = chatViewModel.createChatRoom(listOf(pubkey))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
@@ -286,13 +290,9 @@ fun ReceiverChip(
|
|||||||
pubkey: PublicKey,
|
pubkey: PublicKey,
|
||||||
onRemove: () -> Unit
|
onRemove: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||||
InputChip(
|
InputChip(
|
||||||
@@ -300,7 +300,7 @@ fun ReceiverChip(
|
|||||||
onClick = onRemove,
|
onClick = onRemove,
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "No name",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
@@ -308,8 +308,8 @@ fun ReceiverChip(
|
|||||||
},
|
},
|
||||||
avatar = {
|
avatar = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "No name",
|
||||||
size = 24.dp
|
size = 24.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -372,13 +372,9 @@ fun ContactListItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
val metadata by metadataFlow.collectAsState(initial = null)
|
val profile by profileFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
|
||||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
|
||||||
val picture = profile?.picture
|
|
||||||
|
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
@@ -390,15 +386,15 @@ fun ContactListItem(
|
|||||||
),
|
),
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = profile?.picture,
|
||||||
description = displayName,
|
description = profile?.name ?: "",
|
||||||
size = 36.dp
|
size = 36.dp
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
supportingContent = { Text(text = pubkey.short()) },
|
supportingContent = { Text(text = pubkey.short()) },
|
||||||
content = {
|
content = {
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = profile?.name ?: "",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,244 +1,33 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.remember
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.runtime.setValue
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.ui.Alignment
|
import su.reya.coop.LocalAuthViewModel
|
||||||
import androidx.compose.ui.Modifier
|
import su.reya.coop.LocalNavigator
|
||||||
import androidx.compose.ui.draw.clip
|
import su.reya.coop.Screen
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import su.reya.coop.shared.ProfileEditor
|
||||||
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
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen(
|
fun NewIdentityScreen() {
|
||||||
isLoading: Boolean,
|
val authViewModel = LocalAuthViewModel.current
|
||||||
onBack: () -> Unit,
|
val navigator = LocalNavigator.current
|
||||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
val scope = rememberCoroutineScope()
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
var bio by remember { mutableStateOf("") }
|
|
||||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val authState by authViewModel.state.collectAsStateWithLifecycle()
|
||||||
contract = ActivityResultContracts.GetContent()
|
val isBusy = authState.isBusy
|
||||||
) { uri: Uri? ->
|
|
||||||
picture = uri
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
ProfileEditor(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
title = "Create a new identity",
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
buttonLabel = "Continue",
|
||||||
topBar = {
|
isBusy = isBusy,
|
||||||
TopAppBar(
|
onBack = { navigator.goBack() },
|
||||||
title = {
|
onConfirm = { name, bio, bytes, type ->
|
||||||
Text(
|
scope.launch {
|
||||||
text = "Create a new identity",
|
authViewModel.createIdentity(name, bio, bytes, type)
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
navigator.navigate(Screen.Home)
|
||||||
)
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -9,16 +10,21 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@@ -26,27 +32,77 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.graphics.drawscope.translate
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.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.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalAuthViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
fun OnboardingScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val authViewModel = LocalAuthViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
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(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
||||||
) {
|
|
||||||
LogoRepeatingBackground(
|
LogoRepeatingBackground(
|
||||||
painter = logoPainter,
|
painter = logoPainter,
|
||||||
logosPerRow = 6,
|
logosPerRow = 6,
|
||||||
@@ -54,55 +110,108 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
|||||||
horizontalOffset = 0.5f
|
horizontalOffset = 0.5f
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(2f)
|
.fillMaxSize()
|
||||||
.fillMaxWidth(),
|
.padding(bottom = innerPadding.calculateBottomPadding() + 16.dp),
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
// TODO: Add headline
|
Spacer(modifier = Modifier.weight(2f))
|
||||||
}
|
Surface(
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding()),
|
.padding(24.dp),
|
||||||
contentAlignment = Alignment.BottomEnd,
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shadowElevation = 4.dp,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onOpenNew,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.size(ButtonDefaults.LargeContainerHeight),
|
.padding(24.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Start messaging",
|
text = "Get Started",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
|
||||||
)
|
fontFamily = expressiveFont,
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = onOpenImport,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(ButtonDefaults.LargeContainerHeight),
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
),
|
),
|
||||||
|
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 = { navigator.navigate(Screen.NewIdentity) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Import identity",
|
text = "Start Messaging",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (authViewModel.isExternalSignerAvailable()) {
|
||||||
|
try {
|
||||||
|
authViewModel.connectExternalSigner()
|
||||||
|
navigator.navigate(Screen.Home)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val result = snackbarHostState.showSnackbar(
|
||||||
|
message = "External signer not installed. Please install Amber or alternatives.",
|
||||||
|
actionLabel = "Install",
|
||||||
|
withDismissAction = true,
|
||||||
|
duration = SnackbarDuration.Long
|
||||||
|
)
|
||||||
|
if (result == SnackbarResult.ActionPerformed) {
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
"https://zapstore.dev/apps/com.greenart7c3.nostrsigner".toUri()
|
||||||
|
)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Connect with Amber",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
TextButton(
|
||||||
|
onClick = { navigator.navigate(Screen.Import) },
|
||||||
|
modifier = Modifier
|
||||||
|
.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,
|
rotationDegrees: Float = 0f,
|
||||||
horizontalOffset: Float = 0.5f
|
horizontalOffset: Float = 0.5f
|
||||||
) {
|
) {
|
||||||
val tintColor = MaterialTheme.colorScheme.primary
|
val tintColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val canvasWidth = size.width
|
val canvasWidth = size.width
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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.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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_chat
|
||||||
|
import coop.composeapp.generated.resources.ic_share
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
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 nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val metadata = profile?.metadata?.asRecord()
|
||||||
|
val nip05 = metadata?.nip05 ?: pubkey.short()
|
||||||
|
val picture = metadata?.picture
|
||||||
|
|
||||||
|
val details = remember(profile) {
|
||||||
|
listOf(
|
||||||
|
"Username:" to (metadata?.name ?: "None"),
|
||||||
|
"Website:" to (metadata?.website ?: "None"),
|
||||||
|
"₿ Lightning Address:" to (metadata?.lud16 ?: "None"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { /* empty */ },
|
||||||
|
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 = profile?.name ?: "No name",
|
||||||
|
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 =
|
||||||
|
chatViewModel.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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,48 +3,80 @@ package su.reya.coop.screens
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.selectableGroup
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.PlainTooltip
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SegmentedListItem
|
import androidx.compose.material3.SegmentedListItem
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.TooltipAnchorPosition
|
||||||
|
import androidx.compose.material3.TooltipBox
|
||||||
|
import androidx.compose.material3.TooltipDefaults
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTooltipState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_check
|
||||||
|
import coop.composeapp.generated.resources.ic_close
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayScreen(
|
fun RelayScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||||
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
||||||
|
|
||||||
@@ -60,9 +92,12 @@ fun RelayScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var openAddRelayDialog by remember { mutableStateOf(false) }
|
||||||
|
var relayToDelete by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
relayList.putAll(viewModel.currentUserRelayList())
|
relayList.putAll(nostrViewModel.currentUserRelayList())
|
||||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
msgRelayList.addAll(nostrViewModel.currentUserMsgRelayList())
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -80,15 +115,39 @@ fun RelayScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
TooltipBox(
|
||||||
|
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||||
|
TooltipAnchorPosition.Above,
|
||||||
|
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||||
|
),
|
||||||
|
tooltip = {
|
||||||
|
PlainTooltip { Text("New Relay") }
|
||||||
|
},
|
||||||
|
state = rememberTooltipState(),
|
||||||
|
) {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { openAddRelayDialog = true },
|
||||||
|
expanded = false,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "New Relay"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = { Text("New Relay") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -113,7 +172,8 @@ fun RelayScreen(
|
|||||||
if (msgRelayList.isNotEmpty()) {
|
if (msgRelayList.isNotEmpty()) {
|
||||||
msgRelayList.forEachIndexed { index, relayUrl ->
|
msgRelayList.forEachIndexed { index, relayUrl ->
|
||||||
SegmentedListItem(
|
SegmentedListItem(
|
||||||
onClick = { },
|
onClick = { /* No action */ },
|
||||||
|
onLongClick = { relayToDelete = relayUrl.toString() },
|
||||||
shapes = ListItemDefaults.segmentedShapes(
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
index = index,
|
index = index,
|
||||||
count = msgRelayList.size
|
count = msgRelayList.size
|
||||||
@@ -233,4 +293,223 @@ fun RelayScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (openAddRelayDialog) {
|
||||||
|
AddRelayDialog(
|
||||||
|
onDismissRequest = { openAddRelayDialog = false },
|
||||||
|
onMsgRelayAdded = { newRelay ->
|
||||||
|
msgRelayList.add(RelayUrl.parse(newRelay))
|
||||||
|
},
|
||||||
|
onRelayAdded = { newRelay, metadata ->
|
||||||
|
relayList[RelayUrl.parse(newRelay)] = metadata
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relayToDelete != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { relayToDelete = null },
|
||||||
|
title = { Text("Remove Relay") },
|
||||||
|
text = { Text("Are you sure you want to remove $relayToDelete?") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (msgRelayList.size == 1) {
|
||||||
|
snackbarHostState.showSnackbar("You must have at least one relay")
|
||||||
|
relayToDelete = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
nostrViewModel.removeMsgRelay(relayToDelete!!)
|
||||||
|
msgRelayList.removeIf { it.toString() == relayToDelete }
|
||||||
|
relayToDelete = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Failed to remove relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Confirm")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { relayToDelete = null }) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun AddRelayDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onMsgRelayAdded: (newRelay: String) -> Unit,
|
||||||
|
onRelayAdded: (newRelay: String, metadata: RelayMetadata?) -> Unit,
|
||||||
|
) {
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
var relayAddress by remember { mutableStateOf("") }
|
||||||
|
var isError by remember { mutableStateOf(false) }
|
||||||
|
val roles = listOf("Messaging", "Inbox", "Outbox")
|
||||||
|
val (selected, onSelected) = remember { mutableStateOf(roles[0]) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
decorFitsSystemWindows = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Relay",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onDismissRequest() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_close),
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (!isError) {
|
||||||
|
when (selected) {
|
||||||
|
"Messaging" -> {
|
||||||
|
nostrViewModel.addMsgRelay(relayAddress)
|
||||||
|
onMsgRelayAdded(relayAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
"Inbox" -> {
|
||||||
|
nostrViewModel.addInboxRelay(relayAddress)
|
||||||
|
onRelayAdded(relayAddress, RelayMetadata.WRITE)
|
||||||
|
}
|
||||||
|
|
||||||
|
"Outbox" -> {
|
||||||
|
nostrViewModel.addOutboxRelay(relayAddress)
|
||||||
|
onRelayAdded(relayAddress, RelayMetadata.READ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check),
|
||||||
|
contentDescription = "Add"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = relayAddress,
|
||||||
|
onValueChange = { relayAddress = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
isError = isError,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
isError = relayAddress.isNotEmpty() && !verifyRelayUrl(relayAddress)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text(text = "Relay Address") },
|
||||||
|
placeholder = { Text(text = "wss://relay.example.com") },
|
||||||
|
supportingText = {
|
||||||
|
if (isError) {
|
||||||
|
Text(text = "Invalid format. Must start with wss://")
|
||||||
|
} else {
|
||||||
|
Text(text = "Only add relays you trust.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Relay Roles",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectableGroup(),
|
||||||
|
) {
|
||||||
|
roles.forEach { text ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.selectable(
|
||||||
|
onClick = { onSelected(text) },
|
||||||
|
selected = (text == selected),
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = (text == selected),
|
||||||
|
onClick = null
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyRelayUrl(url: String): Boolean {
|
||||||
|
return try {
|
||||||
|
RelayUrl.parse(url)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse relay url: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||||
|
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun RequestListScreen() {
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
|
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Get all request rooms
|
||||||
|
val requests = remember(chatRooms) {
|
||||||
|
chatRooms.filter { it.kind == RoomKind.Request }
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "New Requests",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = org.jetbrains.compose.resources.painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
PullToRefreshBox(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
state = pullToRefreshState,
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch {
|
||||||
|
isRefreshing = true
|
||||||
|
chatViewModel.refreshChatRooms()
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
indicator = {
|
||||||
|
PullToRefreshDefaults.LoadingIndicator(
|
||||||
|
state = pullToRefreshState,
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (requests.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No requests yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "New chat requests will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(requests.toList(), key = { it.id }) { room ->
|
||||||
|
ChatRoom(
|
||||||
|
room = room,
|
||||||
|
onClick = { navigator.navigate(Screen.Chat(room.id, true)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,15 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -30,34 +29,29 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
||||||
import org.publicvalue.multiplatform.qrcode.CodeType
|
import org.publicvalue.multiplatform.qrcode.CodeType
|
||||||
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanScreen(
|
fun ScanScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val qrScanResult = LocalScanResult.current
|
||||||
val onResult: (String) -> Unit = { result ->
|
|
||||||
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
CenterAlignedTopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "Scan QR",
|
text = "Scan a Nostr Address",
|
||||||
style = MaterialTheme.typography.titleMediumEmphasized
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -76,8 +70,8 @@ fun ScanScreen(
|
|||||||
ScannerWithPermissions(
|
ScannerWithPermissions(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onScanned = {
|
onScanned = {
|
||||||
println("Scanned: $it");
|
qrScanResult.content = it
|
||||||
onResult(it)
|
navigator.goBack()
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
types = listOf(CodeType.QR),
|
types = listOf(CodeType.QR),
|
||||||
@@ -102,14 +96,6 @@ fun ScanScreen(
|
|||||||
.align(Alignment.Center)
|
.align(Alignment.Center)
|
||||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||||
)
|
)
|
||||||
Text(
|
|
||||||
text = "Scan a Nostr address",
|
|
||||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 64.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.shared.ProfileEditor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateProfileScreen() {
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val isBusy by nostrViewModel.isBusy.collectAsStateWithLifecycle(false)
|
||||||
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
val profile = currentUser?.metadata?.asRecord()
|
||||||
|
|
||||||
|
ProfileEditor(
|
||||||
|
title = "Update profile",
|
||||||
|
buttonLabel = "Save changes",
|
||||||
|
initialName = profile?.displayName ?: profile?.name ?: "",
|
||||||
|
initialBio = profile?.about ?: "",
|
||||||
|
initialPicture = profile?.picture,
|
||||||
|
isBusy = isBusy,
|
||||||
|
onBack = { navigator.goBack() },
|
||||||
|
onConfirm = { name, bio, bytes, type ->
|
||||||
|
scope.launch {
|
||||||
|
nostrViewModel.updateProfile(name, bio, bytes, type)
|
||||||
|
navigator.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_cancel
|
||||||
|
import coop.composeapp.generated.resources.ic_check_circle
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.humanReadable
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ScreenerCard(room: Room) {
|
||||||
|
val pubkey = room.members.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var isContact by remember { mutableStateOf(false) }
|
||||||
|
var mutualContacts by remember { mutableStateOf<Set<PublicKey>>(emptySet()) }
|
||||||
|
var lastActivity by remember { mutableStateOf<Timestamp?>(null) }
|
||||||
|
|
||||||
|
val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) }
|
||||||
|
val profile by profileFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
LaunchedEffect(pubkey) {
|
||||||
|
scope.launch {
|
||||||
|
// Check contact
|
||||||
|
nostrViewModel.verifyContact(pubkey).let { isContact = it }
|
||||||
|
// Get mutual contacts
|
||||||
|
nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it }
|
||||||
|
// Get the last activity
|
||||||
|
nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 48.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
picture = profile?.picture,
|
||||||
|
description = "Profile picture",
|
||||||
|
modifier = Modifier.size(120.dp),
|
||||||
|
shape = MaterialShapes.Cookie12Sided.toShape(),
|
||||||
|
)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = profile?.name ?: "No name",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = pubkey.short(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (isContact) "Contact" else "Not a contact",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel
|
||||||
|
),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_check_circle),
|
||||||
|
contentDescription = "Warning",
|
||||||
|
tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}",
|
||||||
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DateSeparator(date: String) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_add_circle
|
||||||
|
import coop.composeapp.generated.resources.ic_audio
|
||||||
|
import coop.composeapp.generated.resources.ic_send
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatInput(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onUpload: () -> Unit,
|
||||||
|
onMicClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = { Text("Message") },
|
||||||
|
leadingIcon = {
|
||||||
|
IconButton(onClick = onUpload) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_add_circle),
|
||||||
|
contentDescription = "Upload",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = value.isNotEmpty(),
|
||||||
|
transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) },
|
||||||
|
label = "send_mic_transition"
|
||||||
|
) { isNotEmpty ->
|
||||||
|
if (isNotEmpty) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onSend,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_send),
|
||||||
|
contentDescription = "Send"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onMicClick,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_audio),
|
||||||
|
contentDescription = "Speech to Text"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.URL_REGEX
|
||||||
|
import su.reya.coop.formatAsTime
|
||||||
|
import su.reya.coop.isImageUrl
|
||||||
|
import su.reya.coop.removeImageUrls
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class MessageUiModel(
|
||||||
|
val id: String,
|
||||||
|
val annotatedContent: AnnotatedString,
|
||||||
|
val images: List<String>,
|
||||||
|
val timestamp: String,
|
||||||
|
val isMine: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberMessageUiModel(
|
||||||
|
event: UnsignedEvent,
|
||||||
|
currentUserPublicKey: PublicKey?,
|
||||||
|
contentColor: Color
|
||||||
|
): MessageUiModel {
|
||||||
|
return remember(event, currentUserPublicKey, contentColor) {
|
||||||
|
val content = event.content()
|
||||||
|
val images = URL_REGEX.findAll(content)
|
||||||
|
.map { it.value }
|
||||||
|
.filter { it.isImageUrl() }
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
val cleanedContent = content.removeImageUrls()
|
||||||
|
|
||||||
|
val annotatedString = buildAnnotatedString {
|
||||||
|
var lastIndex = 0
|
||||||
|
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
|
||||||
|
append(cleanedContent.substring(lastIndex, matchResult.range.first))
|
||||||
|
val url = matchResult.value
|
||||||
|
pushLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url = url,
|
||||||
|
styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = contentColor,
|
||||||
|
textDecoration = TextDecoration.Underline,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
append(url)
|
||||||
|
pop()
|
||||||
|
lastIndex = matchResult.range.last + 1
|
||||||
|
}
|
||||||
|
append(cleanedContent.substring(lastIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageUiModel(
|
||||||
|
id = event.id()?.toHex() ?: event.hashCode().toString(),
|
||||||
|
annotatedContent = annotatedString,
|
||||||
|
images = images,
|
||||||
|
timestamp = event.createdAt().formatAsTime(),
|
||||||
|
isMine = event.author() == currentUserPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatMessage(model: MessageUiModel) {
|
||||||
|
var isMessageClicked by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val bubbleShape = if (model.isMine) {
|
||||||
|
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
} else {
|
||||||
|
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val containerColor =
|
||||||
|
if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
|
||||||
|
|
||||||
|
val contentColor =
|
||||||
|
if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
isMessageClicked = !isMessageClicked
|
||||||
|
},
|
||||||
|
horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
if (model.annotatedContent.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
shape = bubbleShape,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.annotatedContent,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.images.forEach { imageUrl ->
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.widthIn(max = 280.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageUrl,
|
||||||
|
contentDescription = "Image from chat",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isMessageClicked,
|
||||||
|
enter = fadeIn() + expandVertically(),
|
||||||
|
exit = fadeOut() + shrinkVertically()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = model.timestamp,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
modifier = Modifier.align(
|
||||||
|
if (model.isMine) Alignment.End else Alignment.Start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
package su.reya.coop.screens.chat
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.ScaffoldDefaults
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.LocalChatViewModel
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.formatAsGroupHeader
|
||||||
|
import su.reya.coop.rememberUiState
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(id: Long, screening: Boolean = false) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val nostrViewModel = LocalNostrViewModel.current
|
||||||
|
val chatViewModel = LocalChatViewModel.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser by nostrViewModel.currentUserProfile.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Get chat room by ID
|
||||||
|
val chatRooms by chatViewModel.chatRooms.collectAsStateWithLifecycle()
|
||||||
|
val room by remember(id) { derivedStateOf { chatRooms.firstOrNull { it.id == id } } }
|
||||||
|
|
||||||
|
// Show empty screen
|
||||||
|
if (room == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Something went wrong.",
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val roomState by (room as Room).rememberUiState(nostrViewModel, currentUser?.publicKey)
|
||||||
|
var text by remember { mutableStateOf("") }
|
||||||
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
var requireScreening by remember { mutableStateOf(screening) }
|
||||||
|
|
||||||
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
|
val groupedMessages =
|
||||||
|
remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
|
||||||
|
|
||||||
|
val sendFile = { uri: Uri ->
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
// Read file
|
||||||
|
val file = withContext(Dispatchers.IO) {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file content type
|
||||||
|
val type = context.contentResolver.getType(uri)
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
chatViewModel.sendFileMessage(id, file, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
snackbarHostState.showSnackbar("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
uri?.let { sendFile(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val sttLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
val data = result.data
|
||||||
|
val results = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||||
|
if (!results.isNullOrEmpty()) text = results[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(id) {
|
||||||
|
// Get messages
|
||||||
|
val initialMessages = chatViewModel.getChatRoomMessages(id)
|
||||||
|
messages.clear()
|
||||||
|
messages.addAll(initialMessages)
|
||||||
|
|
||||||
|
// Stop loading spinner
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
// Get msg relays for each member
|
||||||
|
chatViewModel.chatRoomConnect(id)
|
||||||
|
|
||||||
|
// Handle new messages
|
||||||
|
chatViewModel.newEvents.collect { event ->
|
||||||
|
if (event.roomId() == id) {
|
||||||
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
|
messages.add(0, event)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the event is not in the current room, it's a new message from another user
|
||||||
|
newOtherMessages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets.union(WindowInsets.ime),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
room?.members?.firstOrNull()?.let { pubkey ->
|
||||||
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
if (loading) {
|
||||||
|
LoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
|
} else {
|
||||||
|
Avatar(
|
||||||
|
picture = roomState.picture,
|
||||||
|
description = roomState.name,
|
||||||
|
size = 32.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = roomState.name,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (newOtherMessages > 0) {
|
||||||
|
Badge {
|
||||||
|
Text(newOtherMessages.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding()),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||||
|
) {
|
||||||
|
if (requireScreening) {
|
||||||
|
room?.let { ScreenerCard(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
val otherColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
when (messages.isNotEmpty()) {
|
||||||
|
true -> {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
reverseLayout = true,
|
||||||
|
state = listState,
|
||||||
|
) {
|
||||||
|
groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
|
||||||
|
items(
|
||||||
|
items = messagesInGroup,
|
||||||
|
key = { it.id()?.toHex() ?: it.hashCode().toString() }
|
||||||
|
) { event ->
|
||||||
|
val isMine = currentUser?.publicKey == event.author()
|
||||||
|
val uiModel = rememberMessageUiModel(
|
||||||
|
event = event,
|
||||||
|
currentUserPublicKey = currentUser?.publicKey,
|
||||||
|
contentColor = if (isMine) mineColor else otherColor
|
||||||
|
)
|
||||||
|
ChatMessage(model = uiModel)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
DateSeparator(dateHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No messages yet",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your conversations will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (requireScreening) {
|
||||||
|
true -> {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { navigator.goBack() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Reject",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { requireScreening = false },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Accept",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
ChatInput(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
onSend = {
|
||||||
|
chatViewModel.sendMessage(id, text)
|
||||||
|
text = ""
|
||||||
|
},
|
||||||
|
onUpload = {
|
||||||
|
fileLauncher.launch("image/*")
|
||||||
|
},
|
||||||
|
onMicClick = {
|
||||||
|
val intent =
|
||||||
|
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(
|
||||||
|
RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM
|
||||||
|
)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak now...")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sttLauncher.launch(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showSnackbar("Speech recognition not available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package su.reya.coop.shared
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LoadingIndicator
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileEditor(
|
||||||
|
title: String,
|
||||||
|
buttonLabel: String,
|
||||||
|
initialName: String = "",
|
||||||
|
initialBio: String = "",
|
||||||
|
initialPicture: Any? = null, // Accepts Uri (picked) or String (current URL)
|
||||||
|
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
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var name by remember(initialName) { mutableStateOf(initialName) }
|
||||||
|
var bio by remember(initialBio) { mutableStateOf(initialBio) }
|
||||||
|
var picture by remember(initialPicture) { mutableStateOf(initialPicture) }
|
||||||
|
|
||||||
|
val hasPicture = remember(picture) {
|
||||||
|
when (picture) {
|
||||||
|
null -> false
|
||||||
|
is String -> (picture as CharSequence).isNotBlank()
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
picture = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(title, style = MaterialTheme.typography.titleMediumEmphasized) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.clip(MaterialShapes.Pentagon.toShape())
|
||||||
|
.clickable { launcher.launch("image/*") },
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (hasPicture) {
|
||||||
|
AsyncImage(
|
||||||
|
model = picture,
|
||||||
|
contentDescription = "Profile picture",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_plus),
|
||||||
|
contentDescription = "Pick avatar",
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onTertiaryFixed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f, fill = true),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "What others should call you?",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryFixedDim,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"Alice",
|
||||||
|
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Your bio (optional)",
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
BasicTextField(
|
||||||
|
value = bio,
|
||||||
|
onValueChange = { bio = it },
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxLines = 3,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
color = MaterialTheme.colorScheme.primaryFixed,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (bio.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"I love cat",
|
||||||
|
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||||
|
alpha = 0.5f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package su.reya.coop.shared
|
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import su.reya.coop.NostrViewModel
|
|
||||||
import su.reya.coop.Room
|
|
||||||
import su.reya.coop.short
|
|
||||||
|
|
||||||
fun Room.displayNameFlow(viewModel: NostrViewModel): Flow<String> {
|
|
||||||
if (!subject.isNullOrBlank()) return flowOf<String>(subject!!)
|
|
||||||
|
|
||||||
val memberFlows = members.map { viewModel.getMetadata(it) }
|
|
||||||
|
|
||||||
return combine(memberFlows) { metadataArray ->
|
|
||||||
if (isGroup()) {
|
|
||||||
val profiles = metadataArray.map { it?.asRecord() }
|
|
||||||
val names = profiles.take(2).mapNotNull { it?.name ?: it?.displayName }
|
|
||||||
var combined = names.joinToString(", ")
|
|
||||||
if (profiles.size > 2) combined += ", +${profiles.size - 2}"
|
|
||||||
combined.ifBlank { "Unknown group" }
|
|
||||||
} else {
|
|
||||||
val profile = metadataArray.firstOrNull()?.asRecord()
|
|
||||||
profile?.name ?: profile?.displayName ?: members.firstOrNull()?.short() ?: "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Room.pictureFlow(viewModel: NostrViewModel): Flow<String?> {
|
|
||||||
val firstMember = members.firstOrNull() ?: return kotlinx.coroutines.flow.flowOf(null)
|
|
||||||
return viewModel.getMetadata(firstMember).map { it?.asRecord()?.picture }
|
|
||||||
}
|
|
||||||
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 551 B |
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
|
After Width: | Height: | Size: 391 B |
|
After Width: | Height: | Size: 727 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
22
composeApp/src/androidMain/res/drawable/coop.xml
Normal 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>
|
||||||
4
composeApp/src/androidMain/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="secondaryContainer">#F8FF37</color>
|
||||||
|
</resources>
|
||||||
9
composeApp/src/androidMain/res/values/themes.xml
Normal 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>
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "37"
|
||||||
android-minSdk = "24"
|
android-minSdk = "26"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "37"
|
||||||
androidx-activity = "1.13.0"
|
androidx-activity = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core = "1.18.0"
|
androidx-core = "1.18.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-navigation = "2.8.8"
|
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
composeMultiplatform = "1.10.3"
|
androidx-splashscreen = "1.2.0"
|
||||||
|
composeMultiplatform = "1.11.1"
|
||||||
|
datastorePreferences = "1.2.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.4.0"
|
||||||
kotlinx-serialization = "1.8.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
material3 = "1.10.0-alpha05"
|
material3 = "1.11.0-alpha07"
|
||||||
ktor = "3.4.3"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
|
ktor = "3.5.1"
|
||||||
|
|
||||||
[libraries]
|
[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-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
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-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-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
|
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-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-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", 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" }
|
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-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
|
||||||
|
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.androidLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
|
alias(libs.plugins.composeMultiplatform)
|
||||||
|
alias(libs.plugins.composeCompiler)
|
||||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,15 +27,16 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
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.core)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||||
|
implementation("su.reya:nostr-sdk-kmp:0.3.2")
|
||||||
|
implementation("com.squareup.okio:okio:3.17.0")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
26
shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
|
||||||
|
fun PublicKey.short(): String {
|
||||||
|
val bech32 = toBech32()
|
||||||
|
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
|
||||||
|
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
|
||||||
|
|
||||||
|
fun String.extractUrls(): List<String> {
|
||||||
|
return URL_REGEX.findAll(this).map { it.value }.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.removeImageUrls(): String {
|
||||||
|
return URL_REGEX.replace(this) { result ->
|
||||||
|
if (result.value.isImageUrl()) "" else result.value
|
||||||
|
}.replace(Regex("\\s+"), " ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.isImageUrl(): Boolean {
|
||||||
|
val extension = this.substringAfterLast('.', "").lowercase()
|
||||||
|
return extension in imageExtensions
|
||||||
|
}
|
||||||
@@ -1,870 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
|
||||||
import io.ktor.client.request.get
|
|
||||||
import io.ktor.client.statement.HttpResponse
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import rust.nostr.sdk.AckPolicy
|
|
||||||
import rust.nostr.sdk.Alphabet
|
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
|
||||||
import rust.nostr.sdk.Client
|
|
||||||
import rust.nostr.sdk.ClientBuilder
|
|
||||||
import rust.nostr.sdk.ClientNotification
|
|
||||||
import rust.nostr.sdk.Contact
|
|
||||||
import rust.nostr.sdk.Event
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Filter
|
|
||||||
import rust.nostr.sdk.GossipConfig
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.Kind
|
|
||||||
import rust.nostr.sdk.KindStandard
|
|
||||||
import rust.nostr.sdk.LogLevel
|
|
||||||
import rust.nostr.sdk.Metadata
|
|
||||||
import rust.nostr.sdk.MetadataRecord
|
|
||||||
import rust.nostr.sdk.Nip05Address
|
|
||||||
import rust.nostr.sdk.Nip05Profile
|
|
||||||
import rust.nostr.sdk.NostrDatabase
|
|
||||||
import rust.nostr.sdk.NostrGossip
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayCapabilities
|
|
||||||
import rust.nostr.sdk.RelayMessageEnum
|
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.ReqExitPolicy
|
|
||||||
import rust.nostr.sdk.ReqTarget
|
|
||||||
import rust.nostr.sdk.SendEventTarget
|
|
||||||
import rust.nostr.sdk.SingleLetterTag
|
|
||||||
import rust.nostr.sdk.SleepWhenIdle
|
|
||||||
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.TagKind
|
|
||||||
import rust.nostr.sdk.Timestamp
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import rust.nostr.sdk.UnwrappedGift
|
|
||||||
import rust.nostr.sdk.extractRelayList
|
|
||||||
import rust.nostr.sdk.giftWrapAsync
|
|
||||||
import rust.nostr.sdk.initLogger
|
|
||||||
import rust.nostr.sdk.nip17ExtractRelayList
|
|
||||||
import kotlin.time.Duration
|
|
||||||
|
|
||||||
object NostrManager {
|
|
||||||
val instance = Nostr()
|
|
||||||
}
|
|
||||||
|
|
||||||
class Nostr {
|
|
||||||
private val _isInitialized = MutableStateFlow(false)
|
|
||||||
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
|
||||||
|
|
||||||
var client: Client? = null
|
|
||||||
private set
|
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
|
||||||
private set
|
|
||||||
var deviceSigner: AsyncNostrSigner? = null
|
|
||||||
private set
|
|
||||||
var sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
|
||||||
private set
|
|
||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
|
||||||
private set
|
|
||||||
|
|
||||||
suspend fun init(dbPath: String) {
|
|
||||||
try {
|
|
||||||
if (_isInitialized.value) return
|
|
||||||
|
|
||||||
// Initialize the logger for nostr client
|
|
||||||
initLogger(LogLevel.DEBUG)
|
|
||||||
|
|
||||||
val lmdb = NostrDatabase.lmdb(dbPath)
|
|
||||||
val gossip = NostrGossip.inMemory()
|
|
||||||
val idleTimeout = Duration.parse("5m")
|
|
||||||
val httpClient = HttpClient {
|
|
||||||
install(WebSockets)
|
|
||||||
}
|
|
||||||
|
|
||||||
client =
|
|
||||||
ClientBuilder()
|
|
||||||
.signer(signer)
|
|
||||||
.websocketTransport(CoopWebSocketClient(httpClient))
|
|
||||||
.database(lmdb)
|
|
||||||
.gossip(gossip)
|
|
||||||
.gossipConfig(
|
|
||||||
GossipConfig()
|
|
||||||
.noBackgroundRefresh()
|
|
||||||
.fetchTimeout(Duration.parse("2s"))
|
|
||||||
.syncIdleTimeout(Duration.parse("100ms"))
|
|
||||||
.syncInitialTimeout(Duration.parse("100ms"))
|
|
||||||
)
|
|
||||||
.verifySubscriptions(false)
|
|
||||||
.automaticAuthentication(true)
|
|
||||||
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
_isInitialized.value = true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun waitUntilInitialized() {
|
|
||||||
_isInitialized.first { it }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
|
||||||
// Bootstrap relays
|
|
||||||
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
|
|
||||||
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
|
|
||||||
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
|
|
||||||
|
|
||||||
|
|
||||||
// Indexer relay for NIP-65 discovery
|
|
||||||
client?.addRelay(
|
|
||||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
|
||||||
capabilities = RelayCapabilities.gossip()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Connect to all bootstrap relays and wait for all connections to be established
|
|
||||||
client?.connect(Duration.parse("2s"))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun disconnect() {
|
|
||||||
client?.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun exit() {
|
|
||||||
signer.switch(Keys.generate())
|
|
||||||
deviceSigner = null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
|
||||||
try {
|
|
||||||
signer.switch(new)
|
|
||||||
// Fetch metadata for current user
|
|
||||||
getUserMetadata()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isSignedByUser(event: Event): Boolean {
|
|
||||||
return try {
|
|
||||||
signer.currentUser == event.author()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to check if event is signed by user: ${e.message}")
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getUserMetadata() {
|
|
||||||
try {
|
|
||||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
// Get the latest metadata event
|
|
||||||
val metadataFilter =
|
|
||||||
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
|
||||||
|
|
||||||
// Get the latest contact list event
|
|
||||||
val contactFilter =
|
|
||||||
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
|
||||||
|
|
||||||
// Get the latest messaging relay list event
|
|
||||||
val msgRelayFilter =
|
|
||||||
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
|
||||||
|
|
||||||
// Construct a target that includes all filters
|
|
||||||
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getUserMessages(msgRelayList: Event) {
|
|
||||||
try {
|
|
||||||
val author = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
val relays = nip17ExtractRelayList(msgRelayList)
|
|
||||||
|
|
||||||
// Ensure relay connections
|
|
||||||
relays.forEach { relay ->
|
|
||||||
client?.addRelay(relay)
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct a filter for gift wrap events
|
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
|
||||||
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
|
||||||
relays.forEach { relay ->
|
|
||||||
target[relay] = listOf(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
client?.subscribe(
|
|
||||||
target = ReqTarget.manual(target),
|
|
||||||
id = "all-gift-wraps"
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun 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 {
|
|
||||||
val now = Timestamp.now()
|
|
||||||
val processedEvent = mutableSetOf<EventId>()
|
|
||||||
val notifications = client?.notifications() ?: return@coroutineScope
|
|
||||||
|
|
||||||
var eoseTrackerJob: Job? = null
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val notification = notifications.next() ?: continue
|
|
||||||
|
|
||||||
when (notification) {
|
|
||||||
is ClientNotification.Message -> {
|
|
||||||
val relayUrl = notification.relayUrl
|
|
||||||
|
|
||||||
when (val message = notification.message.asEnum()) {
|
|
||||||
is RelayMessageEnum.EventMsg -> {
|
|
||||||
val event = message.event
|
|
||||||
val id = message.subscriptionId
|
|
||||||
|
|
||||||
// Prevent processing duplicate events
|
|
||||||
if (processedEvent.contains(event.id())) continue
|
|
||||||
processedEvent.add(event.id())
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.METADATA) == true) {
|
|
||||||
try {
|
|
||||||
val metadata = Metadata.fromJson(event.content())
|
|
||||||
onMetadataUpdate(event.author(), metadata)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to parse metadata: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
|
|
||||||
if (isSignedByUser(event = event)) {
|
|
||||||
onContactListUpdate(event.tags().publicKeys())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
|
|
||||||
// Get all gift wrap events for the current user
|
|
||||||
if (isSignedByUser(event = event)) {
|
|
||||||
getUserMessages(msgRelayList = event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) {
|
|
||||||
try {
|
|
||||||
val rumor = extractRumor(event)
|
|
||||||
|
|
||||||
// Logic to notify UI after processing
|
|
||||||
// Cancel previous tracker if it exists
|
|
||||||
eoseTrackerJob?.cancel()
|
|
||||||
// Start a new tracker
|
|
||||||
eoseTrackerJob = launch {
|
|
||||||
delay(10000) // Wait for 10 seconds
|
|
||||||
onSubscriptionClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle new message
|
|
||||||
rumor?.createdAt()?.asSecs()?.let {
|
|
||||||
if (it >= now.asSecs()) {
|
|
||||||
onNewMessage(rumor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to extract rumor: $e")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is RelayMessageEnum.EndOfStoredEvents -> {
|
|
||||||
val subscriptionId = message.subscriptionId
|
|
||||||
|
|
||||||
if (subscriptionId == "all-gift-wraps" || subscriptionId == "newest-gift-wraps") {
|
|
||||||
onSubscriptionClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is RelayMessageEnum.Ok -> {
|
|
||||||
if (sentEvents.containsKey(message.eventId)) {
|
|
||||||
val currentRelays = sentEvents[message.eventId] ?: emptyList()
|
|
||||||
sentEvents[message.eventId] = currentRelays + relayUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
/* Ignore other message types */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ClientNotification.Shutdown -> {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
/* Ignore other message types */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
|
||||||
try {
|
|
||||||
val filter = Filter().identifier(giftId.toHex())
|
|
||||||
val event = client?.database()?.query(filter)?.first()
|
|
||||||
|
|
||||||
return event?.content()?.let { UnsignedEvent.fromJson(it) }
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
// Ensure the rumor ID is set
|
|
||||||
val rumor = rumor.ensureId()
|
|
||||||
val roomId = rumor.roomId()
|
|
||||||
|
|
||||||
// Construct reference tags
|
|
||||||
val tags = listOf(
|
|
||||||
Tag.identifier(giftId.toHex()),
|
|
||||||
Tag.event(rumor.id()!!),
|
|
||||||
Tag.reference(roomId.toString()),
|
|
||||||
Tag.custom(TagKind.Unknown("k"), listOf("dm"))
|
|
||||||
)
|
|
||||||
|
|
||||||
// Set event kind
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA);
|
|
||||||
|
|
||||||
val event = EventBuilder(kind, rumor.asJson())
|
|
||||||
.tags(tags)
|
|
||||||
.build(currentUser)
|
|
||||||
.signWithKeys(Keys.generate())
|
|
||||||
|
|
||||||
client?.database()?.saveEvent(event)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to set cached rumor: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun extractRumor(event: Event): UnsignedEvent? {
|
|
||||||
// Check if the rumor is already cached
|
|
||||||
val cachedRumor = getCachedRumor(event.id())
|
|
||||||
if (cachedRumor != null) return cachedRumor
|
|
||||||
|
|
||||||
// Get all signers
|
|
||||||
val signers = listOfNotNull(signer, deviceSigner)
|
|
||||||
if (signers.isEmpty()) return null
|
|
||||||
|
|
||||||
// Try to unwrap the gift with each signer
|
|
||||||
for (signer in signers) {
|
|
||||||
try {
|
|
||||||
// TODO: custom unwrapping logic
|
|
||||||
val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event)
|
|
||||||
val rumor = gift.rumor()
|
|
||||||
// Save the rumor to the database
|
|
||||||
setCachedRumor(event.id(), rumor)
|
|
||||||
// Return the rumor
|
|
||||||
return rumor
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to unwrap gift: ${e.message}")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
|
||||||
// Construct a list of relays
|
|
||||||
val relayList = mapOf<RelayUrl, RelayMetadata>(
|
|
||||||
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
|
||||||
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
|
||||||
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
|
||||||
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ensure all relays are added and connected
|
|
||||||
relayList.forEach { (relay, metadata) ->
|
|
||||||
client?.addRelay(
|
|
||||||
url = relay,
|
|
||||||
capabilities =
|
|
||||||
if (metadata == RelayMetadata.READ) RelayCapabilities.read()
|
|
||||||
else if (metadata == RelayMetadata.WRITE) RelayCapabilities.write()
|
|
||||||
else RelayCapabilities.none()
|
|
||||||
)
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
return relayList
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
|
||||||
// Construct a list of messaging relays
|
|
||||||
val msgRelayList = listOf(
|
|
||||||
RelayUrl.parse("wss://relay.0xchat.com"),
|
|
||||||
RelayUrl.parse("wss://nip17.com"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Ensure all relays are added and connected
|
|
||||||
msgRelayList.forEach { relay ->
|
|
||||||
client?.addRelay(relay, RelayCapabilities.none())
|
|
||||||
client?.connectRelay(relay)
|
|
||||||
}
|
|
||||||
|
|
||||||
return msgRelayList
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
|
||||||
// Send relay list event
|
|
||||||
val relayList = getDefaultRelayList()
|
|
||||||
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = relayListEvent,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.all(),
|
|
||||||
okTimeout = Duration.parse("3s")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send messaging relay list event
|
|
||||||
val msgRelayList = getDefaultMsgRelayList()
|
|
||||||
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).signWithKeys(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = msgRelayListEvent,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send metadata event
|
|
||||||
val metadata =
|
|
||||||
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
|
||||||
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = metadataEvent,
|
|
||||||
target = SendEventTarget.broadcast(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send contact list event
|
|
||||||
val defaultContact =
|
|
||||||
listOf(Contact(publicKey = PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x")))
|
|
||||||
val contactListEvent = EventBuilder.contactList(defaultContact).signWithKeys(keys)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = contactListEvent,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none()
|
|
||||||
)
|
|
||||||
|
|
||||||
setSigner(keys)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
|
||||||
try {
|
|
||||||
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(200u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
val results = mutableMapOf<PublicKey, Metadata>()
|
|
||||||
|
|
||||||
events?.toVec()?.forEach { event ->
|
|
||||||
val metadata = Metadata.fromJson(event.content())
|
|
||||||
results[event.author()] = metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get cache metadata: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
|
||||||
try {
|
|
||||||
val limit = keys.size.toULong() * 4u;
|
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
// Construct a filter for metadata events
|
|
||||||
val filter = Filter()
|
|
||||||
.kind(Kind.fromStd(KindStandard.METADATA))
|
|
||||||
.authors(keys)
|
|
||||||
.limit(limit)
|
|
||||||
|
|
||||||
// Construct a target that includes all filters
|
|
||||||
val target =
|
|
||||||
ReqTarget.manual(
|
|
||||||
mapOf(
|
|
||||||
RelayUrl.parse("wss://purplepag.es") to listOf(filter),
|
|
||||||
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
|
|
||||||
RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
client?.subscribe(target = target, closeOn = opts)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
|
||||||
try {
|
|
||||||
val event = EventBuilder.nip17RelayList(urls).signAsync(signer)
|
|
||||||
|
|
||||||
client?.sendEvent(
|
|
||||||
event = event,
|
|
||||||
target = SendEventTarget.toNip65(),
|
|
||||||
ackPolicy = AckPolicy.none(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS);
|
|
||||||
val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u)
|
|
||||||
val target = ReqTarget.auto(listOf(filter))
|
|
||||||
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
|
||||||
|
|
||||||
client?.subscribe(target = target, closeOn = opts)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
|
||||||
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRooms(): Set<Room>? {
|
|
||||||
try {
|
|
||||||
val userPubkey = signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
|
||||||
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
|
||||||
|
|
||||||
// Get all events sent by the user
|
|
||||||
val filter = Filter().kind(kind).author(userPubkey).customTag(kTag, "dm")
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
// Collect rooms
|
|
||||||
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
|
||||||
|
|
||||||
events
|
|
||||||
?.toVec()
|
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
|
||||||
?.filter { it.tags().publicKeys().isNotEmpty() }
|
|
||||||
?.forEach { event ->
|
|
||||||
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
|
||||||
val existingRoom = roomsMap[newRoom.id]
|
|
||||||
|
|
||||||
// Check if the room already exists
|
|
||||||
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
|
||||||
val filter =
|
|
||||||
Filter().kind(kind).author(userPubkey).pubkeys(newRoom.members.toList())
|
|
||||||
|
|
||||||
// Determine if it's an ongoing room
|
|
||||||
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false
|
|
||||||
|
|
||||||
// Append room to map
|
|
||||||
roomsMap[newRoom.id] =
|
|
||||||
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println("Failed to get chat rooms: ${e.message}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
|
||||||
val filter = Filter().kind(kind).reference(roomId.toString())
|
|
||||||
val events = client?.database()?.query(filter)
|
|
||||||
|
|
||||||
// Merge the events
|
|
||||||
return events
|
|
||||||
?.toVec()
|
|
||||||
?.map { UnsignedEvent.fromJson(it.content()) }
|
|
||||||
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun chatRoomConnect(members: List<PublicKey>): Map<PublicKey, List<RelayUrl>> {
|
|
||||||
try {
|
|
||||||
val results = mutableMapOf<PublicKey, MutableList<RelayUrl>>()
|
|
||||||
|
|
||||||
members.forEach { member ->
|
|
||||||
results[member] = mutableListOf<RelayUrl>()
|
|
||||||
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
|
||||||
val filter = Filter().kind(kind).author(member).limit(1u)
|
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
|
||||||
target = ReqTarget.auto(listOf(filter)),
|
|
||||||
id = "room-${member.toBech32().substring(0, 10)}",
|
|
||||||
timeout = Duration.parse("3s"),
|
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
|
||||||
)
|
|
||||||
|
|
||||||
stream?.next()?.let { res ->
|
|
||||||
if (res.event != null) {
|
|
||||||
// Connect to the msg relays
|
|
||||||
connectMsgRelays(res.event!!)
|
|
||||||
// Mark the member as connected
|
|
||||||
results[member]?.add(res.relayUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun connectMsgRelays(event: Event) {
|
|
||||||
try {
|
|
||||||
val urls = nip17ExtractRelayList(event);
|
|
||||||
for (url in urls) {
|
|
||||||
if (client?.relay(url) == null) {
|
|
||||||
client?.addRelay(url)
|
|
||||||
client?.connectRelay(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendMessage(
|
|
||||||
to: List<PublicKey>,
|
|
||||||
content: String,
|
|
||||||
subject: String? = null,
|
|
||||||
replies: List<EventId> = emptyList(),
|
|
||||||
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
val currentUser =
|
|
||||||
signer.currentUser ?: throw IllegalStateException("User not signed in")
|
|
||||||
|
|
||||||
val tags = mutableListOf<Tag>()
|
|
||||||
|
|
||||||
// Add a subject tag if provided
|
|
||||||
if (subject != null) {
|
|
||||||
tags.add(Tag.custom(TagKind.Subject, listOf(subject)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add event tags for replies
|
|
||||||
if (replies.isNotEmpty()) {
|
|
||||||
replies.forEach { replyId ->
|
|
||||||
tags.add(Tag.event(replyId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add public key tags for each recipient
|
|
||||||
to.forEach { pubkey ->
|
|
||||||
if (pubkey != currentUser) {
|
|
||||||
tags.add(Tag.publicKey(pubkey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (receiver in listOf(currentUser) + to) {
|
|
||||||
// Construct the rumor event
|
|
||||||
// NEVER SIGN this event with the current user signer
|
|
||||||
val rumor = EventBuilder
|
|
||||||
.privateMsgRumor(receiver = receiver, message = content)
|
|
||||||
.tags(tags)
|
|
||||||
.build(currentUser)
|
|
||||||
// Ensure the event ID is set
|
|
||||||
.ensureId()
|
|
||||||
|
|
||||||
// Emit the rumor to the chat screen
|
|
||||||
if (receiver == currentUser) {
|
|
||||||
onRumorCreated?.invoke(rumor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the gift wrap event
|
|
||||||
val gift = giftWrapAsync(
|
|
||||||
signer = signer,
|
|
||||||
receiverPubkey = receiver,
|
|
||||||
rumor = rumor,
|
|
||||||
extraTags = listOf(
|
|
||||||
Tag.custom(TagKind.Unknown("k"), listOf("14"))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Send the event to receiver's NIP-17 relays
|
|
||||||
val output = client?.sendEvent(
|
|
||||||
event = gift,
|
|
||||||
target = SendEventTarget.toNip17(),
|
|
||||||
ackPolicy = AckPolicy.none(),
|
|
||||||
authenticationTimeout = Duration.parse("2s")
|
|
||||||
)
|
|
||||||
|
|
||||||
if (output != null) {
|
|
||||||
// Keep track of sent events
|
|
||||||
sentEvents[output.id] = emptyList()
|
|
||||||
if (rumor.id() != null) rumorMap[rumor.id()!!] = output.id
|
|
||||||
|
|
||||||
// Collect failed outputs
|
|
||||||
output.failed.forEach { (relayUrl, reason) ->
|
|
||||||
println("Failed to send event to relay $relayUrl: $reason")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
|
||||||
try {
|
|
||||||
val response: HttpResponse = client.get(address.url())
|
|
||||||
val bodyString: String = response.body()
|
|
||||||
|
|
||||||
return Nip05Profile.fromJson(address, bodyString)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey {
|
|
||||||
try {
|
|
||||||
val address = Nip05Address.parse(query)
|
|
||||||
val profile = profileFromAddress(HttpClient(), address)
|
|
||||||
|
|
||||||
return profile.publicKey()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
// Add search relay
|
|
||||||
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
|
|
||||||
if (client?.relay(searchRelay) == null) {
|
|
||||||
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
|
|
||||||
client?.connectRelay(searchRelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
|
||||||
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
|
||||||
val target =
|
|
||||||
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
|
|
||||||
|
|
||||||
val stream = client?.streamEvents(
|
|
||||||
target = target,
|
|
||||||
id = "search",
|
|
||||||
timeout = Duration.parse("4s"),
|
|
||||||
policy = ReqExitPolicy.ExitOnEose
|
|
||||||
)
|
|
||||||
|
|
||||||
// Collect the results
|
|
||||||
val results = mutableListOf<PublicKey>()
|
|
||||||
|
|
||||||
// Keep searching until the stream is closed or timeout
|
|
||||||
stream?.next()?.let { event ->
|
|
||||||
if (event.event != null) {
|
|
||||||
results.add(event.event!!.author())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
} catch (e: Exception) {
|
|
||||||
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,533 +0,0 @@
|
|||||||
package su.reya.coop
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import rust.nostr.sdk.EventBuilder
|
|
||||||
import rust.nostr.sdk.EventId
|
|
||||||
import rust.nostr.sdk.Keys
|
|
||||||
import rust.nostr.sdk.Metadata
|
|
||||||
import rust.nostr.sdk.NostrConnect
|
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
|
||||||
import rust.nostr.sdk.PublicKey
|
|
||||||
import rust.nostr.sdk.RelayMetadata
|
|
||||||
import rust.nostr.sdk.RelayUrl
|
|
||||||
import rust.nostr.sdk.Tag
|
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
|
||||||
import su.reya.coop.blossom.BlossomClient
|
|
||||||
import su.reya.coop.storage.SecretStorage
|
|
||||||
import kotlin.time.Clock
|
|
||||||
import kotlin.time.Duration
|
|
||||||
|
|
||||||
class NostrViewModel(
|
|
||||||
private val nostr: Nostr,
|
|
||||||
private val secretStore: SecretStorage
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _emptySecret = MutableStateFlow<Boolean?>(null)
|
|
||||||
val emptySecret = _emptySecret.asStateFlow()
|
|
||||||
|
|
||||||
private val _isCreating = MutableStateFlow(false)
|
|
||||||
val isCreating = _isCreating.asStateFlow()
|
|
||||||
|
|
||||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
|
||||||
val chatRooms = _chatRooms.asStateFlow()
|
|
||||||
|
|
||||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
|
||||||
val contactList = _contactList.asStateFlow()
|
|
||||||
|
|
||||||
private val _isPartialProcessedGiftWrap = MutableStateFlow(false)
|
|
||||||
val isPartialProcessedGiftWrap = _isPartialProcessedGiftWrap.asStateFlow()
|
|
||||||
|
|
||||||
private val _isRelayListEmpty = MutableStateFlow(false)
|
|
||||||
val isRelayListEmpty = _isRelayListEmpty.asStateFlow()
|
|
||||||
|
|
||||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
|
||||||
val newEvents = _newEvents.asSharedFlow()
|
|
||||||
|
|
||||||
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
|
|
||||||
val sentReport = _sentReports.asSharedFlow()
|
|
||||||
|
|
||||||
private val _errorEvents = Channel<String>(Channel.BUFFERED)
|
|
||||||
val errorEvents = _errorEvents.receiveAsFlow()
|
|
||||||
|
|
||||||
private val _metadataStore = mutableMapOf<PublicKey, MutableStateFlow<Metadata?>>()
|
|
||||||
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
|
||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
startNotificationHandler()
|
|
||||||
startMetadataBatchHandler()
|
|
||||||
getCacheMetadata()
|
|
||||||
login()
|
|
||||||
observeSignerAndCheckRelays()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
// Ensure all relays are disconnect
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
nostr.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showError(message: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_errorEvents.send(message)
|
|
||||||
if (isCreating.value) _isCreating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startNotificationHandler() {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startMetadataBatchHandler() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
val batch = mutableSetOf<PublicKey>()
|
|
||||||
val timeout = 500L // 500ms timeout for batching
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val firstKey = metadataRequestChannel.receive()
|
|
||||||
batch.add(firstKey)
|
|
||||||
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
|
||||||
|
|
||||||
while (batch.isNotEmpty()) {
|
|
||||||
val nextKey = withTimeoutOrNull(timeout) {
|
|
||||||
metadataRequestChannel.receive()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextKey != null) {
|
|
||||||
batch.add(nextKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
|
||||||
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
|
||||||
val keysToRequest = batch.toList()
|
|
||||||
batch.clear()
|
|
||||||
|
|
||||||
nostr.fetchMetadataBatch(keysToRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCacheMetadata() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
val results = nostr.getAllCacheMetadata()
|
|
||||||
results.forEach { (pubkey, metadata) ->
|
|
||||||
updateMetadata(pubkey, metadata)
|
|
||||||
seenPublicKeys.add(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
// Wait until the client is ready
|
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
// 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
|
|
||||||
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}")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Invalid secret format: $secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeSignerAndCheckRelays() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
while (true) {
|
|
||||||
val pubkey = nostr.signer.currentUser
|
|
||||||
|
|
||||||
if (pubkey != null) {
|
|
||||||
delay(3000)
|
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
|
||||||
if (relays.isEmpty()) {
|
|
||||||
_isRelayListEmpty.value = true
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestMetadata(pubkey: PublicKey) {
|
|
||||||
if (seenPublicKeys.add(pubkey)) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
metadataRequestChannel.send(pubkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateMetadata(pubkey: PublicKey, metadata: Metadata) {
|
|
||||||
_metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }.value = metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMetadata(pubkey: PublicKey): StateFlow<Metadata?> {
|
|
||||||
val flow = _metadataStore.getOrPut(pubkey) { MutableStateFlow(null) }
|
|
||||||
if (flow.value == null) {
|
|
||||||
requestMetadata(pubkey)
|
|
||||||
}
|
|
||||||
return flow.asStateFlow()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun currentUser(): PublicKey? {
|
|
||||||
return nostr.signer.currentUser
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
secretStore.clear("user_signer")
|
|
||||||
nostr.signer.switch(Keys.generate())
|
|
||||||
_emptySecret.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissRelayWarning() {
|
|
||||||
_isRelayListEmpty.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun getOrInitAppKeys(): Keys {
|
|
||||||
val secret = secretStore.get("app_keys")
|
|
||||||
|
|
||||||
// If app keys are already stored, use them
|
|
||||||
if (secret != null) {
|
|
||||||
return Keys.parse(secret)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new app keys and save to the secret storage
|
|
||||||
val keys = Keys.generate()
|
|
||||||
secretStore.set("app_keys", keys.secretKey().toBech32())
|
|
||||||
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIdentity(
|
|
||||||
name: String,
|
|
||||||
bio: String?,
|
|
||||||
picture: ByteArray?,
|
|
||||||
contentType: String? = null
|
|
||||||
) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val keys = Keys.generate()
|
|
||||||
val secret = keys.secretKey().toBech32()
|
|
||||||
var avatarUrl = ""
|
|
||||||
|
|
||||||
// Set loading state
|
|
||||||
_isCreating.value = true
|
|
||||||
|
|
||||||
// Upload picture to Blossom
|
|
||||||
if (picture != null) {
|
|
||||||
val blossom = BlossomClient(
|
|
||||||
url = "https://blossom.band",
|
|
||||||
client = HttpClient {
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val descriptor = blossom.upload(
|
|
||||||
file = picture,
|
|
||||||
contentType = contentType,
|
|
||||||
signer = keys
|
|
||||||
)
|
|
||||||
|
|
||||||
avatarUrl = descriptor?.url ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create identity
|
|
||||||
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
|
|
||||||
|
|
||||||
// Save secret to the secret storage
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
|
|
||||||
// Set an empty secret state
|
|
||||||
_emptySecret.value = false
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
|
||||||
if (secret.startsWith("nsec1")) {
|
|
||||||
val keys = Keys.parse(secret)
|
|
||||||
return keys.publicKey()
|
|
||||||
} else if (secret.startsWith("bunker://")) {
|
|
||||||
val appKeys = getOrInitAppKeys()
|
|
||||||
val bunker = NostrConnectUri.parse(secret)
|
|
||||||
val timeout = Duration.parse("50s") // 50 seconds timeout
|
|
||||||
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
|
|
||||||
|
|
||||||
// Show toast to ask user to approve the connection
|
|
||||||
showError("Please approve the connection.")
|
|
||||||
|
|
||||||
return remote.getPublicKeyAsync()
|
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Invalid secret: $secret")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun importIdentity(secret: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (secret.startsWith("nsec1")) {
|
|
||||||
val keys = Keys.parse(secret)
|
|
||||||
nostr.setSigner(keys)
|
|
||||||
secretStore.set("user_signer", secret)
|
|
||||||
// Set an empty secret state
|
|
||||||
_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 useDefaultMsgRelayList() {
|
|
||||||
try {
|
|
||||||
val defaultRelays = nostr.getDefaultMsgRelayList()
|
|
||||||
nostr.setMsgRelays(defaultRelays)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
|
||||||
try {
|
|
||||||
return nostr.getRelayList(nostr.signer.currentUser!!)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyMap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
|
||||||
try {
|
|
||||||
return nostr.getMsgRelays(nostr.signer.currentUser!!)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
|
||||||
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!!)
|
|
||||||
|
|
||||||
// Create a room from the rumor event
|
|
||||||
val room = Room.new(rumor, nostr.signer.currentUser!!)
|
|
||||||
_chatRooms.value += room
|
|
||||||
|
|
||||||
return room.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room {
|
|
||||||
return chatRooms.value.firstOrNull { it.id == id }
|
|
||||||
?: throw IllegalArgumentException("Room not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getChatRooms() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun refreshChatRooms() {
|
|
||||||
try {
|
|
||||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
|
||||||
try {
|
|
||||||
return nostr.getChatRoomMessages(roomId)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun chatRoomConnect(roomId: Long): Map<PublicKey, List<RelayUrl>> {
|
|
||||||
val room = getChatRoom(roomId)
|
|
||||||
val members = room.members
|
|
||||||
|
|
||||||
return runCatching {
|
|
||||||
nostr.chatRoomConnect(members.toList())
|
|
||||||
}.getOrElse { e ->
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
members.associateWith { emptyList<RelayUrl>() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val room = getChatRoom(roomId)
|
|
||||||
nostr.sendMessage(
|
|
||||||
to = room.members.toList(),
|
|
||||||
content = message,
|
|
||||||
subject = room.subject,
|
|
||||||
replies = replies,
|
|
||||||
onRumorCreated = { event ->
|
|
||||||
updateRoomList(roomId, event)
|
|
||||||
viewModelScope.launch { _newEvents.emit(event) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isMessageSent(id: EventId): Boolean {
|
|
||||||
val giftWrapId = nostr.rumorMap[id]
|
|
||||||
|
|
||||||
if (giftWrapId != null) {
|
|
||||||
val isSent = nostr.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
|
||||||
return isSent
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
|
||||||
_chatRooms.value = _chatRooms.value.map { room ->
|
|
||||||
if (room.id == roomId) {
|
|
||||||
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt())
|
|
||||||
} else {
|
|
||||||
room
|
|
||||||
}
|
|
||||||
}.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
|
||||||
try {
|
|
||||||
return nostr.searchByAddress(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun searchByNostr(query: String): List<PublicKey> {
|
|
||||||
try {
|
|
||||||
return nostr.searchByNostr(query)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
showError("Error: ${e.message}")
|
|
||||||
}
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun PublicKey.short(): String {
|
|
||||||
val bech32 = toBech32()
|
|
||||||
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
|
|
||||||
}
|
|
||||||
20
shared/src/commonMain/kotlin/su/reya/coop/Profile.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package su.reya.coop
|
||||||
|
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
|
||||||
|
data class Profile(
|
||||||
|
val publicKey: PublicKey,
|
||||||
|
val metadata: Metadata
|
||||||
|
) {
|
||||||
|
private val record by lazy { metadata.asRecord() }
|
||||||
|
|
||||||
|
val name: String
|
||||||
|
get() = record.displayName ?: record.name ?: publicKey.short()
|
||||||
|
|
||||||
|
val picture: String?
|
||||||
|
get() = record.picture
|
||||||
|
|
||||||
|
val shortPublicKey: String
|
||||||
|
get() = publicKey.short()
|
||||||
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.State
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.minus
|
import kotlinx.datetime.minus
|
||||||
import kotlinx.datetime.number
|
import kotlinx.datetime.number
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.TagKind
|
|
||||||
import rust.nostr.sdk.Timestamp
|
import rust.nostr.sdk.Timestamp
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.viewmodel.NostrViewModel
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -37,14 +44,17 @@ data class Room(
|
|||||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||||
val id = rumor.roomId()
|
val id = rumor.roomId()
|
||||||
val createdAt = rumor.createdAt()
|
val createdAt = rumor.createdAt()
|
||||||
val subject = rumor.tags().find(TagKind.Subject)?.content()
|
val subject = rumor.tags().toVec().find { it.kind() == "subject" }?.content()
|
||||||
|
|
||||||
// Collect the author's public key and all public keys from tags
|
// Collect the author's public key and all public keys from tags
|
||||||
// Also remove the user's public key from the list, current user is always a member
|
|
||||||
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
||||||
pubkeys.add(rumor.author())
|
pubkeys.add(rumor.author())
|
||||||
pubkeys.addAll(rumor.tags().publicKeys())
|
pubkeys.addAll(rumor.tags().publicKeys())
|
||||||
|
|
||||||
|
// Also remove the user's public key from the list, current user is always a member
|
||||||
|
if (pubkeys.size > 1 && pubkeys.contains(userPubkey)) {
|
||||||
pubkeys.remove(userPubkey)
|
pubkeys.remove(userPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new Room instance
|
// Create a new Room instance
|
||||||
return Room(
|
return Room(
|
||||||
@@ -73,9 +83,60 @@ data class Room(
|
|||||||
return this.copy(lastMessage = message)
|
return this.copy(lastMessage = message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isGroup(): Boolean {
|
fun isGroup(): Boolean = members.size > 1
|
||||||
return members.size > 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class RoomUiState(
|
||||||
|
val name: String = "Loading...",
|
||||||
|
val picture: String? = null,
|
||||||
|
val isGroup: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
fun Room.uiStateFlow(
|
||||||
|
nostrViewModel: NostrViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): Flow<RoomUiState> {
|
||||||
|
val displayMembers = if (isGroup()) members.take(2) else members.take(1)
|
||||||
|
|
||||||
|
if (!subject.isNullOrBlank()) {
|
||||||
|
return flowOf(RoomUiState(name = subject, isGroup = isGroup()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return combine(displayMembers.map { nostrViewModel.getMetadata(it) }) { profiles ->
|
||||||
|
val names = profiles.mapIndexed { i, profile -> profile?.name ?: displayMembers[i].short() }
|
||||||
|
|
||||||
|
val name = when {
|
||||||
|
isGroup() -> {
|
||||||
|
val combined = names.joinToString(", ")
|
||||||
|
val extra = members.size - names.size
|
||||||
|
if (extra > 0) "$combined, +$extra" else combined
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val first = names.firstOrNull() ?: "Unknown"
|
||||||
|
if (displayMembers.firstOrNull() == currentUser) "$first (you)" else first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomUiState(
|
||||||
|
name = name,
|
||||||
|
picture = profiles.firstOrNull()?.picture,
|
||||||
|
isGroup = isGroup()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Room.rememberUiState(
|
||||||
|
viewModel: NostrViewModel,
|
||||||
|
currentUser: PublicKey? = null
|
||||||
|
): State<RoomUiState> {
|
||||||
|
return remember(this, currentUser) {
|
||||||
|
uiStateFlow(
|
||||||
|
viewModel,
|
||||||
|
currentUser
|
||||||
|
)
|
||||||
|
}.collectAsStateWithLifecycle(RoomUiState())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun UnsignedEvent.roomId(): Long {
|
fun UnsignedEvent.roomId(): Long {
|
||||||
@@ -92,21 +153,25 @@ fun UnsignedEvent.roomId(): Long {
|
|||||||
return sortedUniqueKeys.hashCode().toLong()
|
return sortedUniqueKeys.hashCode().toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Timestamp.ago(): String {
|
fun Timestamp.formatAsTime(): String {
|
||||||
val SECONDS_IN_MINUTE = 60L
|
val timeZone = TimeZone.currentSystemDefault()
|
||||||
val MINUTES_IN_HOUR = 60L
|
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val HOURS_IN_DAY = 24L
|
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
|
||||||
val DAYS_IN_MONTH = 30L
|
val hour = inputDateTime.hour.toString().padStart(2, '0')
|
||||||
|
val minute = inputDateTime.minute.toString().padStart(2, '0')
|
||||||
|
return "$hour:$minute"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Timestamp.ago(): String {
|
||||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||||
val now = Clock.System.now()
|
val now = Clock.System.now()
|
||||||
val duration = now - inputInstant
|
val duration = now - inputInstant
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
duration.inWholeSeconds < 60L -> "Now"
|
||||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m"
|
||||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
duration.inWholeHours < 24L -> "${duration.inWholeHours}h"
|
||||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
duration.inWholeDays < 30L -> "${duration.inWholeDays}d"
|
||||||
else -> {
|
else -> {
|
||||||
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
val month =
|
val month =
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class BlossomClient(
|
|||||||
signer: AsyncNostrSigner,
|
signer: AsyncNostrSigner,
|
||||||
authz: BlossomAuthorization
|
authz: BlossomAuthorization
|
||||||
): HeaderValue {
|
): HeaderValue {
|
||||||
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
|
val authEvent = EventBuilder.blossomAuth(authz).finalizeAsync(signer)
|
||||||
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
||||||
val value = "Nostr $encodedAuth"
|
val value = "Nostr $encodedAuth"
|
||||||
return HeaderValue(value)
|
return HeaderValue(value)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform interface for NIP-55 external signer communication.
|
||||||
|
* Implemented on Android; no-op/null on other platforms.
|
||||||
|
*/
|
||||||
|
interface ExternalSignerHandler {
|
||||||
|
fun isAvailable(): Boolean
|
||||||
|
fun setPackageName(packageName: String)
|
||||||
|
suspend fun getPublicKey(permissions: String? = null): ExternalSignerResult?
|
||||||
|
suspend fun signEvent(event: UnsignedEvent, currentUser: PublicKey): String?
|
||||||
|
suspend fun nip04Encrypt(plaintext: String, pubkey: PublicKey): String?
|
||||||
|
suspend fun nip04Decrypt(ciphertext: String, pubkey: PublicKey): String?
|
||||||
|
suspend fun nip44Encrypt(plaintext: String, pubkey: PublicKey, currentUser: PublicKey): String?
|
||||||
|
suspend fun nip44Decrypt(ciphertext: String, pubkey: PublicKey, currentUser: PublicKey): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SignerPermission(
|
||||||
|
val type: String,
|
||||||
|
val kind: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object SignerPermissions {
|
||||||
|
fun signEvent(kind: Int? = null) = SignerPermission(type = "sign_event", kind = kind)
|
||||||
|
fun nip04Encrypt() = SignerPermission(type = "nip04_encrypt")
|
||||||
|
fun nip04Decrypt() = SignerPermission(type = "nip04_decrypt")
|
||||||
|
fun nip44Encrypt() = SignerPermission(type = "nip44_encrypt")
|
||||||
|
fun nip44Decrypt() = SignerPermission(type = "nip44_decrypt")
|
||||||
|
|
||||||
|
fun toJson(permissions: List<SignerPermission>): String {
|
||||||
|
return Json.encodeToString(permissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ExternalSignerResult(
|
||||||
|
val pubkey: PublicKey,
|
||||||
|
val packageName: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
|
||||||
|
class ExternalSignerProxy(
|
||||||
|
private val handler: ExternalSignerHandler,
|
||||||
|
private val currentUser: PublicKey,
|
||||||
|
) : AsyncNostrSigner {
|
||||||
|
override suspend fun getPublicKeyAsync(): PublicKey {
|
||||||
|
return currentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
|
||||||
|
val signedJson = handler.signEvent(unsignedEvent, currentUser) ?: return null
|
||||||
|
return Event.fromJson(signedJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||||
|
return handler.nip04Encrypt(content, publicKey)
|
||||||
|
?: throw Exception("NIP-04 encrypt rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
|
||||||
|
return handler.nip04Decrypt(encryptedContent, publicKey)
|
||||||
|
?: throw Exception("NIP-04 decrypt rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||||
|
return handler.nip44Encrypt(content, publicKey, currentUser)
|
||||||
|
?: throw Exception("NIP-44 encrypt rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||||
|
return handler.nip44Decrypt(payload, publicKey, currentUser)
|
||||||
|
?: throw Exception("NIP-44 decrypt rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
341
shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Alphabet
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SingleLetterTag
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import rust.nostr.sdk.nip59MakeGiftWrapAsync
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.RoomKind
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
data class MessageSyncState(
|
||||||
|
val processedCount: Int = 0,
|
||||||
|
val isSyncing: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
class MessageManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
val sentEvents: MutableMap<EventId, List<RelayUrl>> = mutableMapOf()
|
||||||
|
val rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
|
|
||||||
|
private val _messageSyncState = MutableStateFlow(MessageSyncState())
|
||||||
|
val messageSyncState = _messageSyncState.asStateFlow()
|
||||||
|
|
||||||
|
fun updateSyncState(update: (MessageSyncState) -> MessageSyncState) {
|
||||||
|
_messageSyncState.update(update)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMessages(msgRelayList: Event) {
|
||||||
|
try {
|
||||||
|
val author =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val relays = nip17ExtractRelayList(msgRelayList)
|
||||||
|
|
||||||
|
// Ensure relay connections
|
||||||
|
relays.forEach { relay ->
|
||||||
|
client?.addRelay(relay)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct a filter for gift wrap events
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.GIFT_WRAP)).pubkey(author)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
relays.forEach { relay ->
|
||||||
|
target[relay] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
id = "gift-wraps"
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun extractRumor(event: Event): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
// Gift wrap must have at least one 'p' tag
|
||||||
|
if (event.tags().publicKeys().isEmpty()) {
|
||||||
|
println("No recipient tags found.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event must be a gift wrap
|
||||||
|
if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) {
|
||||||
|
println("Event is not a gift wrap.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the rumor is already cached
|
||||||
|
val cachedRumor = getCachedRumor(event.id())
|
||||||
|
if (cachedRumor != null) return cachedRumor
|
||||||
|
|
||||||
|
// Decrypt the gift wrap event
|
||||||
|
val seal = signer.nip44DecryptAsync(event.author(), event.content())
|
||||||
|
val sealEvent = Event.fromJson(seal)
|
||||||
|
|
||||||
|
// Verify seal event
|
||||||
|
if (!sealEvent.verify()) {
|
||||||
|
println("Failed to verify seal event.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the rumor
|
||||||
|
val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content())
|
||||||
|
val unsignedEvent = UnsignedEvent.fromJson(rumor).ensureId()
|
||||||
|
|
||||||
|
// Ensure the rumor author matches the seal
|
||||||
|
if (unsignedEvent.author() != sealEvent.author()) {
|
||||||
|
println("Author mismatch.")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the rumor for later use
|
||||||
|
setCachedRumor(event.id(), unsignedEvent)
|
||||||
|
|
||||||
|
return unsignedEvent
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getCachedRumor(giftId: EventId): UnsignedEvent? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().identifier(giftId.toHex())
|
||||||
|
val event = client?.database()?.query(filter)?.first()
|
||||||
|
|
||||||
|
return event?.content()?.let { UnsignedEvent.fromJson(it).ensureId() }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw IllegalStateException("Failed to get cached rumor: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) {
|
||||||
|
try {
|
||||||
|
// Construct reference tags
|
||||||
|
val tags = listOf(
|
||||||
|
Tag.identifier(giftId.toHex()),
|
||||||
|
Tag.publicKey(rumor.author()),
|
||||||
|
Tag.custom("r", listOf(rumor.roomId().toString())),
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set event kind
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
|
||||||
|
// Construct event
|
||||||
|
val event = EventBuilder(kind, rumor.asJson())
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeAsync(Keys.generate())
|
||||||
|
|
||||||
|
client?.database()?.saveEvent(event)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
println("Failed to set cached rumor: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRooms(): Set<Room>? {
|
||||||
|
try {
|
||||||
|
val userPubkey =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val kTag = SingleLetterTag.lowercase(Alphabet.K)
|
||||||
|
|
||||||
|
// Get all DM events
|
||||||
|
val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm"))
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Collect rooms
|
||||||
|
val roomsMap: MutableMap<Long, Room> = mutableMapOf()
|
||||||
|
|
||||||
|
events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()) }
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.forEach { event ->
|
||||||
|
val newRoom = Room.new(rumor = event, userPubkey = userPubkey)
|
||||||
|
val existingRoom = roomsMap[newRoom.id]
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
if (existingRoom == null || newRoom.createdAt.asSecs() > existingRoom.createdAt.asSecs()) {
|
||||||
|
val rTag = SingleLetterTag.lowercase(Alphabet.R)
|
||||||
|
val filter = Filter().kind(kind).pubkey(userPubkey)
|
||||||
|
.customTag(rTag, newRoom.id.toString())
|
||||||
|
|
||||||
|
// Determine if it's an ongoing room
|
||||||
|
val isOngoing =
|
||||||
|
client?.database()?.query(filter)?.toVec()?.isNotEmpty() ?: false
|
||||||
|
|
||||||
|
// Append room to map
|
||||||
|
roomsMap[newRoom.id] =
|
||||||
|
if (isOngoing) newRoom.copy(kind = RoomKind.Ongoing) else newRoom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get chat rooms: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA)
|
||||||
|
val filter = Filter().kind(kind).reference(roomId.toString())
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
// Merge the events
|
||||||
|
return events
|
||||||
|
?.toVec()
|
||||||
|
?.map { UnsignedEvent.fromJson(it.content()).ensureId() }
|
||||||
|
// Filter out events without public keys (receivers)
|
||||||
|
?.filter { it.tags().publicKeys().isNotEmpty() }
|
||||||
|
?.sortedByDescending { it.createdAt().asSecs() } ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get chat room messages: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun chatRoomConnect(members: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
members.forEach { member ->
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(member).limit(1u)
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = ReqTarget.auto(listOf(filter)),
|
||||||
|
id = null,
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
stream?.next()?.let { res ->
|
||||||
|
val event = res.event ?: return@let
|
||||||
|
connectMsgRelays(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectMsgRelays(event: Event) {
|
||||||
|
try {
|
||||||
|
val urls = nip17ExtractRelayList(event)
|
||||||
|
for (url in urls) {
|
||||||
|
client?.addRelay(url, RelayCapabilities.gossip())
|
||||||
|
client?.connectRelay(url)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to connect to relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendMessage(
|
||||||
|
to: Set<PublicKey>,
|
||||||
|
content: String,
|
||||||
|
subject: String? = null,
|
||||||
|
replies: List<EventId> = emptyList(),
|
||||||
|
onRumorCreated: ((UnsignedEvent) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val tags = mutableListOf<Tag>()
|
||||||
|
|
||||||
|
// Add a subject tag if provided
|
||||||
|
if (subject != null) {
|
||||||
|
tags.add(Tag.custom("subject", listOf(subject)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event tags for replies
|
||||||
|
if (replies.isNotEmpty()) {
|
||||||
|
replies.forEach { replyId ->
|
||||||
|
tags.add(Tag.event(replyId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add public key tags for each recipient
|
||||||
|
to.forEach { pubkey ->
|
||||||
|
tags.add(Tag.publicKey(pubkey))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (receiver in setOf(currentUser) + to) {
|
||||||
|
// Construct the rumor event
|
||||||
|
// NEVER SIGN this event with the current user signer
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), content)
|
||||||
|
.tags(tags)
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
.ensureId()
|
||||||
|
|
||||||
|
// Emit the rumor to the chat screen
|
||||||
|
if (receiver == currentUser) {
|
||||||
|
onRumorCreated?.invoke(rumor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the gift wrap event
|
||||||
|
val gift = nip59MakeGiftWrapAsync(
|
||||||
|
signer = signer,
|
||||||
|
receiverPubkey = receiver,
|
||||||
|
rumor = rumor,
|
||||||
|
extraTags = listOf(
|
||||||
|
Tag.custom("k", listOf("14"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send the event to receiver's NIP-17 relays
|
||||||
|
val output = client?.sendEvent(
|
||||||
|
event = gift,
|
||||||
|
target = SendEventTarget.toNip17(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
authenticationTimeout = Duration.parse("2s")
|
||||||
|
)
|
||||||
|
|
||||||
|
if (output != null) {
|
||||||
|
// Keep track of sent events
|
||||||
|
sentEvents[output.id] = emptyList()
|
||||||
|
|
||||||
|
// Keep track of rumor IDs
|
||||||
|
val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null")
|
||||||
|
rumorMap[id] = output.id
|
||||||
|
|
||||||
|
// Collect failed outputs
|
||||||
|
output.failed.forEach { (relayUrl, reason) ->
|
||||||
|
println("Failed to send event to relay $relayUrl: $reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to send message: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
267
shared/src/commonMain/kotlin/su/reya/coop/nostr/Nostr.kt
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.ClientBuilder
|
||||||
|
import rust.nostr.sdk.ClientNotification
|
||||||
|
import rust.nostr.sdk.Event
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.GossipConfig
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.LogLevel
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.NostrDatabase
|
||||||
|
import rust.nostr.sdk.NostrGossip
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMessageEnum
|
||||||
|
import rust.nostr.sdk.SignerAuthenticator
|
||||||
|
import rust.nostr.sdk.SleepWhenIdle
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import rust.nostr.sdk.initLogger
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
object NostrManager {
|
||||||
|
val instance = Nostr()
|
||||||
|
|
||||||
|
val BOOTSTRAP_RELAYS = listOf(
|
||||||
|
"wss://relay.primal.net",
|
||||||
|
"wss://relay.ditto.pub",
|
||||||
|
"wss://user.kindpag.es",
|
||||||
|
)
|
||||||
|
|
||||||
|
val INDEXER_RELAY = listOf(
|
||||||
|
"wss://indexer.coracle.social",
|
||||||
|
)
|
||||||
|
|
||||||
|
val ALL_RELAYS = BOOTSTRAP_RELAYS + INDEXER_RELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
class Nostr {
|
||||||
|
var client: Client? = null
|
||||||
|
private set
|
||||||
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
|
private set
|
||||||
|
|
||||||
|
val messages = MessageManager(this)
|
||||||
|
val profiles = ProfileManager(this)
|
||||||
|
val relays = RelayManager(this)
|
||||||
|
|
||||||
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitNewEvent(event: UnsignedEvent) {
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun init(
|
||||||
|
dbPath: String,
|
||||||
|
logLevel: LogLevel = LogLevel.WARN
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (isInitialized.value) return
|
||||||
|
|
||||||
|
// Initialize the logger for nostr client
|
||||||
|
initLogger(logLevel)
|
||||||
|
|
||||||
|
// Initialize configurations for nostr client
|
||||||
|
val lmdb = NostrDatabase.lmdb(dbPath)
|
||||||
|
val gossip = NostrGossip.inMemory()
|
||||||
|
|
||||||
|
// Initialize the authenticator
|
||||||
|
val authenticator = SignerAuthenticator(signer)
|
||||||
|
val idleTimeout = Duration.parse("5m")
|
||||||
|
|
||||||
|
client =
|
||||||
|
ClientBuilder()
|
||||||
|
.authenticator(authenticator)
|
||||||
|
.database(lmdb)
|
||||||
|
.gossip(gossip)
|
||||||
|
.gossipConfig(
|
||||||
|
GossipConfig()
|
||||||
|
.noBackgroundRefresh()
|
||||||
|
.fetchTimeout(Duration.parse("2s"))
|
||||||
|
)
|
||||||
|
.verifySubscriptions(false)
|
||||||
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
isInitialized.value = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun waitUntilInitialized() {
|
||||||
|
isInitialized.first { it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
relays.connectBootstrapRelays()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
relays.reconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
relays.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun prune() {
|
||||||
|
try {
|
||||||
|
client?.database()?.wipe()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to prune database: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
|
try {
|
||||||
|
signer.switch(new)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isSignedByUser(event: Event): Boolean {
|
||||||
|
return try {
|
||||||
|
signer.publicKeyFlow.value == event.author()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to check if event is signed by user: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
suspend fun handleNotifications(
|
||||||
|
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||||
|
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||||
|
onNewMessage: (UnsignedEvent) -> Unit,
|
||||||
|
) = supervisorScope {
|
||||||
|
val now = Timestamp.now()
|
||||||
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
|
val giftWrapQueue = Channel<Event>(Channel.UNLIMITED)
|
||||||
|
var processedCount = 0
|
||||||
|
var eoseReceived = false
|
||||||
|
|
||||||
|
launch(Dispatchers.Default) {
|
||||||
|
for (event in giftWrapQueue) {
|
||||||
|
val rumor = messages.extractRumor(event)
|
||||||
|
processedCount++
|
||||||
|
|
||||||
|
// Trigger new message notification
|
||||||
|
if (rumor != null) {
|
||||||
|
if (rumor.createdAt().asSecs() >= now.asSecs()) {
|
||||||
|
onNewMessage(rumor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sync state
|
||||||
|
messages.updateSyncState {
|
||||||
|
it.copy(
|
||||||
|
processedCount = processedCount,
|
||||||
|
isSyncing = !eoseReceived || !giftWrapQueue.isEmpty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val notification = notifications.next() ?: continue
|
||||||
|
|
||||||
|
when (notification) {
|
||||||
|
is ClientNotification.Message -> {
|
||||||
|
val relayUrl = notification.relayUrl
|
||||||
|
|
||||||
|
when (val message = notification.message.asEnum()) {
|
||||||
|
is RelayMessageEnum.EventMsg -> {
|
||||||
|
val event = message.event
|
||||||
|
|
||||||
|
// Prevent processing duplicate events
|
||||||
|
if (processedEvent.contains(event.id())) continue
|
||||||
|
processedEvent.add(event.id())
|
||||||
|
|
||||||
|
when (event.kind().asStd()) {
|
||||||
|
KindStandard.METADATA -> {
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
onMetadataUpdate(event.author(), metadata)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.CONTACT_LIST -> {
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
val pubkeys = event.tags().publicKeys()
|
||||||
|
// Get mutual contacts
|
||||||
|
profiles.syncMutualContacts(pubkeys)
|
||||||
|
// Emit contact list update
|
||||||
|
onContactListUpdate(pubkeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.INBOX_RELAYS -> {
|
||||||
|
// Get all gift wrap events for the current user
|
||||||
|
if (isSignedByUser(event = event)) {
|
||||||
|
messages.getUserMessages(msgRelayList = event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KindStandard.GIFT_WRAP -> {
|
||||||
|
giftWrapQueue.send(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.EndOfStoredEvents -> {
|
||||||
|
if (message.subscriptionId == "gift-wraps") {
|
||||||
|
eoseReceived = true
|
||||||
|
if (giftWrapQueue.isEmpty) {
|
||||||
|
messages.updateSyncState { it.copy(isSyncing = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is RelayMessageEnum.Ok -> {
|
||||||
|
if (messages.sentEvents.containsKey(message.eventId)) {
|
||||||
|
val currentRelays =
|
||||||
|
messages.sentEvents[message.eventId] ?: emptyList()
|
||||||
|
messages.sentEvents[message.eventId] = currentRelays + relayUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ClientNotification.Shutdown -> {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
/* Ignore other message types */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.Contact
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.Metadata
|
||||||
|
import rust.nostr.sdk.MetadataRecord
|
||||||
|
import rust.nostr.sdk.Nip05Address
|
||||||
|
import rust.nostr.sdk.Nip05Profile
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class ProfileManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
private val _metadataUpdates =
|
||||||
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
||||||
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||||
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) {
|
||||||
|
_metadataUpdates.emit(pubkey to metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) {
|
||||||
|
_contactListUpdates.emit(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getUserMetadata() {
|
||||||
|
try {
|
||||||
|
val author =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Get the latest metadata event
|
||||||
|
val metadataFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.METADATA)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest contact list event
|
||||||
|
val contactFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.CONTACT_LIST)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Get the latest messaging relay list event
|
||||||
|
val msgRelayFilter =
|
||||||
|
Filter().kind(Kind.fromStd(KindStandard.INBOX_RELAYS)).author(author).limit(1u)
|
||||||
|
|
||||||
|
// Construct a target that includes all filters
|
||||||
|
val target = ReqTarget.auto(listOf(metadataFilter, contactFilter, msgRelayFilter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, id = "user-metadata", closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch user metadata: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncMutualContacts(pubkeys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).authors(pubkeys).limit(pubkeys.size.toULong())
|
||||||
|
val relays = NostrManager.BOOTSTRAP_RELAYS.map { RelayUrl.parse(it) }
|
||||||
|
|
||||||
|
client?.sync(filter, relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
|
||||||
|
// Send relay list event
|
||||||
|
val relayList = nostr.relays.getDefaultRelayList()
|
||||||
|
val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = relayListEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.all(),
|
||||||
|
okTimeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send messaging relay list event
|
||||||
|
val msgRelayList = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
val msgRelayListEvent = EventBuilder.nip17RelayList(msgRelayList).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = msgRelayListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send metadata event
|
||||||
|
val metadata =
|
||||||
|
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
|
||||||
|
val metadataEvent = EventBuilder.metadata(metadata).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = metadataEvent,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Send contact list event
|
||||||
|
val defaultContact =
|
||||||
|
Contact(PublicKey.parse("npub1j3rz3ndl902lya6ywxvy5c983lxs8mpukqnx4pa4lt5wrykwl5ys7wpw3x"))
|
||||||
|
val contactListEvent = EventBuilder.contactList(listOf(defaultContact)).finalizeAsync(keys)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = contactListEvent,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
nostr.setSigner(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: String? = null
|
||||||
|
): Metadata {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val record = getLatestMetadata(currentUser)?.asRecord() ?: MetadataRecord()
|
||||||
|
val newRecord = record.copy(
|
||||||
|
displayName = name ?: record.displayName,
|
||||||
|
about = bio ?: record.about,
|
||||||
|
picture = picture ?: record.picture
|
||||||
|
)
|
||||||
|
val newMetadata = Metadata.fromRecord(newRecord)
|
||||||
|
val event = EventBuilder.metadata(newMetadata).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none()
|
||||||
|
)
|
||||||
|
|
||||||
|
return newMetadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to update identity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? {
|
||||||
|
return try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.METADATA)
|
||||||
|
val filter = Filter().kind(kind).author(pubkey).limit(1u)
|
||||||
|
val event = client?.database()?.query(filter)?.first() ?: return null
|
||||||
|
|
||||||
|
Metadata.fromJson(event.content())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get latest metadata: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllCacheMetadata(): Map<PublicKey, Metadata> {
|
||||||
|
try {
|
||||||
|
val filter = Filter().kind(Kind.fromStd(KindStandard.METADATA)).limit(100u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val results = mutableMapOf<PublicKey, Metadata>()
|
||||||
|
|
||||||
|
events?.toVec()?.forEach { event ->
|
||||||
|
try {
|
||||||
|
val metadata = Metadata.fromJson(event.content())
|
||||||
|
results[event.author()] = metadata
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to parse metadata: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to get all cache metadata: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMetadataBatch(keys: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val limit = keys.size.toULong() * 2u
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
// Construct a filter for metadata events
|
||||||
|
val filter = Filter()
|
||||||
|
.kind(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
.authors(keys)
|
||||||
|
.limit(limit)
|
||||||
|
|
||||||
|
// Construct request target
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
client?.subscribe(target = ReqTarget.manual(target), closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setContactList(contacts: List<PublicKey>) {
|
||||||
|
try {
|
||||||
|
val contactList = contacts.map { Contact(it) }
|
||||||
|
val event = EventBuilder.contactList(contactList).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set contact list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
|
||||||
|
try {
|
||||||
|
val response: HttpResponse = client.get(address.url())
|
||||||
|
val bodyString: String = response.body()
|
||||||
|
|
||||||
|
return Nip05Profile.fromJson(address, bodyString)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey {
|
||||||
|
try {
|
||||||
|
val address = Nip05Address.parse(query)
|
||||||
|
val profile = profileFromAddress(HttpClient(), address)
|
||||||
|
|
||||||
|
return profile.publicKey()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search address: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
// Add search relay
|
||||||
|
val searchRelay = RelayUrl.parse("wss://antiprimal.net")
|
||||||
|
if (client?.relay(searchRelay) == null) {
|
||||||
|
client?.addRelay(url = searchRelay, capabilities = RelayCapabilities.read())
|
||||||
|
client?.connectRelay(searchRelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
|
||||||
|
val filter = Filter().kinds(kinds).search(query).limit(10u)
|
||||||
|
val target = ReqTarget.manual(mapOf(searchRelay to listOf(filter)))
|
||||||
|
|
||||||
|
val stream = client?.streamEvents(
|
||||||
|
target = target,
|
||||||
|
id = "search",
|
||||||
|
timeout = Duration.parse("3s"),
|
||||||
|
policy = ReqExitPolicy.ExitOnEose
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collect the results
|
||||||
|
val results = mutableListOf<PublicKey>()
|
||||||
|
|
||||||
|
// Keep searching until the stream is closed or timeout
|
||||||
|
stream?.next()?.let { event ->
|
||||||
|
val event = event.event ?: return@let
|
||||||
|
results.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
try {
|
||||||
|
val filter = Filter().author(pubkey).limit(3u)
|
||||||
|
val target = mutableMapOf<RelayUrl, List<Filter>>()
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { relay ->
|
||||||
|
target[RelayUrl.parse(relay)] = listOf(filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
val events = client?.fetchEvents(
|
||||||
|
target = ReqTarget.manual(target),
|
||||||
|
timeout = Duration.parse("3s")
|
||||||
|
)
|
||||||
|
|
||||||
|
return events?.first()?.createdAt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get latest activity: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val pubkeys = events?.first()?.tags()?.publicKeys() ?: listOf()
|
||||||
|
|
||||||
|
return pubkeys.contains(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
try {
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.CONTACT_LIST)
|
||||||
|
val filter = Filter().kind(kind).pubkey(pubkey).limit(1u)
|
||||||
|
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val contacts = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
events?.toVec()?.filter { it.author() != currentUser }?.forEach { event ->
|
||||||
|
contacts.add(event.author())
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts.toSet()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get mutual contacts: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
shared/src/commonMain/kotlin/su/reya/coop/nostr/RelayManager.kt
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import rust.nostr.sdk.AckPolicy
|
||||||
|
import rust.nostr.sdk.Client
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.Filter
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayCapabilities
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayStatus
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.ReqExitPolicy
|
||||||
|
import rust.nostr.sdk.ReqTarget
|
||||||
|
import rust.nostr.sdk.SendEventTarget
|
||||||
|
import rust.nostr.sdk.SubscribeAutoCloseOptions
|
||||||
|
import rust.nostr.sdk.extractRelayList
|
||||||
|
import rust.nostr.sdk.nip17ExtractRelayList
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class RelayManager(private val nostr: Nostr) {
|
||||||
|
private val client: Client? get() = nostr.client
|
||||||
|
private val signer: UniversalSigner get() = nostr.signer
|
||||||
|
|
||||||
|
suspend fun connectBootstrapRelays() {
|
||||||
|
NostrManager.BOOTSTRAP_RELAYS.forEach { url ->
|
||||||
|
client?.addRelay(RelayUrl.parse(url))
|
||||||
|
}
|
||||||
|
NostrManager.INDEXER_RELAY.forEach { url ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = RelayUrl.parse(url),
|
||||||
|
capabilities = RelayCapabilities.gossip()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Connect to all bootstrap relays
|
||||||
|
client?.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun reconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.relay(RelayUrl.parse(url)).let { relay ->
|
||||||
|
if (relay != null) {
|
||||||
|
if (relay.status() != RelayStatus.CONNECTED) {
|
||||||
|
relay.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to reconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun disconnect() {
|
||||||
|
NostrManager.ALL_RELAYS.forEach { url ->
|
||||||
|
try {
|
||||||
|
client?.disconnectRelay(RelayUrl.parse(url))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Failed to disconnect relay: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultRelayList(): Map<RelayUrl, RelayMetadata> {
|
||||||
|
// Construct a list of relays
|
||||||
|
val relayList = mapOf(
|
||||||
|
RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ,
|
||||||
|
RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE,
|
||||||
|
RelayUrl.parse("wss://nostr.superfriends.online") to RelayMetadata.WRITE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
relayList.forEach { (relay, metadata) ->
|
||||||
|
client?.addRelay(
|
||||||
|
url = relay,
|
||||||
|
capabilities =
|
||||||
|
when (metadata) {
|
||||||
|
RelayMetadata.READ -> RelayCapabilities.read()
|
||||||
|
RelayMetadata.WRITE -> RelayCapabilities.write()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return relayList
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun getDefaultMsgRelayList(): List<RelayUrl> {
|
||||||
|
// Construct a list of messaging relays
|
||||||
|
val msgRelayList = listOf(
|
||||||
|
RelayUrl.parse("wss://auth.nostr1.com"),
|
||||||
|
RelayUrl.parse("wss://nip17.com"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure all relays are added and connected
|
||||||
|
msgRelayList.forEach { relay ->
|
||||||
|
client?.addRelay(relay, RelayCapabilities.none())
|
||||||
|
client?.connectRelay(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return msgRelayList
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setMsgRelays(urls: List<RelayUrl>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.nip17RelayList(urls).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.toNip65(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val currentUser =
|
||||||
|
signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(currentUser).limit(1u)
|
||||||
|
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose)
|
||||||
|
|
||||||
|
client?.subscribe(target = target, closeOn = opts)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
val event = events?.first() ?: return emptyList()
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(event)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchMsgRelays(publicKey: PublicKey): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.INBOX_RELAYS)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val target = ReqTarget.auto(listOf(filter))
|
||||||
|
val events = client?.fetchEvents(target, timeout = Duration.parse("3s"))
|
||||||
|
|
||||||
|
return nip17ExtractRelayList(events?.toVec()?.firstOrNull() ?: return emptyList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to fetch msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRelayList(publicKey: PublicKey): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val kind = Kind.fromStd(KindStandard.RELAY_LIST)
|
||||||
|
val filter = Filter().kind(kind).author(publicKey).limit(1u)
|
||||||
|
val events = client?.database()?.query(filter)
|
||||||
|
|
||||||
|
return extractRelayList(events?.toVec()?.firstOrNull() ?: return emptyMap())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get relay list: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setRelaylist(relays: Map<RelayUrl, RelayMetadata?>) {
|
||||||
|
try {
|
||||||
|
val event = EventBuilder.relayList(relays).finalizeAsync(signer)
|
||||||
|
|
||||||
|
client?.sendEvent(
|
||||||
|
event = event,
|
||||||
|
target = SendEventTarget.broadcast(),
|
||||||
|
ackPolicy = AckPolicy.none(),
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to set msg relays: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,44 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop.nostr
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.Event
|
import rust.nostr.sdk.Event
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
private var signer: AsyncNostrSigner = initialSigner
|
private var signer: AsyncNostrSigner = initialSigner
|
||||||
|
|
||||||
var currentUser: PublicKey? = null
|
private val _publicKeyFlow = MutableStateFlow<PublicKey?>(null)
|
||||||
private set
|
val publicKeyFlow = _publicKeyFlow.asStateFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current signer.
|
* Get the current signer.
|
||||||
*/
|
*/
|
||||||
suspend fun get(): AsyncNostrSigner = mutex.withLock {
|
fun get(): AsyncNostrSigner = signer
|
||||||
signer
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch to a new signer.
|
* Switch to a new signer.
|
||||||
*/
|
*/
|
||||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
||||||
|
val pubkey = try {
|
||||||
|
withTimeoutOrNull(20.seconds) {
|
||||||
|
newSigner.getPublicKeyAsync()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalStateException("Failed to get public key from signer", e)
|
||||||
|
}
|
||||||
signer = newSigner
|
signer = newSigner
|
||||||
currentUser = newSigner.getPublicKeyAsync()
|
_publicKeyFlow.value = pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
|
||||||
|
object ErrorRepository {
|
||||||
|
private val _errors = Channel<String>(Channel.BUFFERED)
|
||||||
|
val errors = _errors.receiveAsFlow()
|
||||||
|
|
||||||
|
fun showError(message: String) {
|
||||||
|
_errors.trySend(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package su.reya.coop.repository
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import su.reya.coop.blossom.BlossomClient
|
||||||
|
|
||||||
|
class MediaRepository {
|
||||||
|
private val httpClient = HttpClient {
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
json(Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
prettyPrint = true
|
||||||
|
isLenient = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun blossomUpload(
|
||||||
|
signer: AsyncNostrSigner,
|
||||||
|
file: ByteArray,
|
||||||
|
contentType: String? = "image/jpeg"
|
||||||
|
): String? {
|
||||||
|
return try {
|
||||||
|
val blossom = BlossomClient(url = "https://blossom.band", client = httpClient)
|
||||||
|
val descriptor = blossom.upload(
|
||||||
|
file = file,
|
||||||
|
contentType = contentType,
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
|
descriptor?.url
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("Upload failed: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
|
import rust.nostr.sdk.Keys
|
||||||
|
import rust.nostr.sdk.NostrConnect
|
||||||
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.nostr.ExternalSignerHandler
|
||||||
|
import su.reya.coop.nostr.ExternalSignerProxy
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.nostr.SignerPermissions
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.storage.SecretStorage
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class AuthState(
|
||||||
|
val isBusy: Boolean = false,
|
||||||
|
val signerRequired: Boolean? = null,
|
||||||
|
val isNotificationBannerDismissed: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class AuthViewModel(
|
||||||
|
private val nostr: Nostr,
|
||||||
|
private val secretStore: SecretStorage,
|
||||||
|
private val externalSignerHandler: ExternalSignerHandler? = null,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_USER_SIGNER = "user_signer"
|
||||||
|
private const val KEY_APP_KEYS = "app_keys"
|
||||||
|
private const val KEY_BANNER_DISMISSED = "notification_banner_dismissed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(AuthState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Check if the notification banner has been dismissed
|
||||||
|
checkNotificationBannerDismissedStatus()
|
||||||
|
|
||||||
|
// Check local stored secret (secret key or bunker)
|
||||||
|
login()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkNotificationBannerDismissedStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val dismissed = secretStore.get(KEY_BANNER_DISMISSED) == "true"
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = dismissed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val secret = withTimeoutOrNull(3.seconds) {
|
||||||
|
secretStore.get(KEY_USER_SIGNER)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret == null) {
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
}.onSuccess {
|
||||||
|
_state.update { it.copy(signerRequired = false) }
|
||||||
|
}.onFailure { e ->
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_state.update { it.copy(signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout(onLogout: () -> Unit = {}) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
|
// Reset the nostr signer and prune the database
|
||||||
|
nostr.signer.switch(Keys.generate())
|
||||||
|
nostr.prune()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Logout encountered an error: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
// Clear credentials from persistent storage
|
||||||
|
secretStore.clear(KEY_USER_SIGNER)
|
||||||
|
secretStore.clear(KEY_BANNER_DISMISSED)
|
||||||
|
|
||||||
|
// Call cleanup callback (e.g. to reset other ViewModels)
|
||||||
|
onLogout()
|
||||||
|
|
||||||
|
_state.update { it.copy(isBusy = false, signerRequired = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissNotificationBanner() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
secretStore.set(KEY_BANNER_DISMISSED, "true")
|
||||||
|
_state.update { it.copy(isNotificationBannerDismissed = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrInitAppKeys(): Keys {
|
||||||
|
val secret = secretStore.get(KEY_APP_KEYS)
|
||||||
|
|
||||||
|
// If app keys are already stored, use them
|
||||||
|
if (secret != null) {
|
||||||
|
return Keys.parse(secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new app keys and save to the secret storage
|
||||||
|
val keys = Keys.generate()
|
||||||
|
secretStore.set(KEY_APP_KEYS, keys.secretKey().toBech32())
|
||||||
|
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||||
|
return when {
|
||||||
|
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||||
|
|
||||||
|
secret.startsWith("bunker://") -> {
|
||||||
|
val appKeys = getOrInitAppKeys()
|
||||||
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
|
val timeout = 50.seconds
|
||||||
|
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.startsWith("nip55://") -> {
|
||||||
|
val handler = externalSignerHandler
|
||||||
|
?: throw IllegalStateException("External signer not available on this platform")
|
||||||
|
|
||||||
|
// Format: nip55://packageName/hexPubkey
|
||||||
|
val parts = secret.removePrefix("nip55://").split("/", limit = 2)
|
||||||
|
val packageName = parts[0]
|
||||||
|
val pubkey = PublicKey.parse(parts[1])
|
||||||
|
|
||||||
|
handler.setPackageName(packageName)
|
||||||
|
ExternalSignerProxy(handler, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
if (secret.startsWith("bunker://")) {
|
||||||
|
showError("Please approve the connection.")
|
||||||
|
}
|
||||||
|
return signer.getPublicKeyAsync()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun importIdentity(secret: String) {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
// Update signer
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set(KEY_USER_SIGNER, secret)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun connectExternalSigner() {
|
||||||
|
val handler = externalSignerHandler ?: throw IllegalStateException("Signer not available")
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val permissions = SignerPermissions.toJson(
|
||||||
|
listOf(
|
||||||
|
SignerPermissions.signEvent(0),
|
||||||
|
SignerPermissions.signEvent(3),
|
||||||
|
SignerPermissions.signEvent(10000),
|
||||||
|
SignerPermissions.signEvent(10050),
|
||||||
|
SignerPermissions.signEvent(10063),
|
||||||
|
SignerPermissions.signEvent(22242),
|
||||||
|
SignerPermissions.signEvent(30030),
|
||||||
|
SignerPermissions.signEvent(30315),
|
||||||
|
SignerPermissions.nip44Encrypt(),
|
||||||
|
SignerPermissions.nip44Decrypt(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = handler.getPublicKey(permissions) ?: throw Exception("Rejected")
|
||||||
|
val signer = ExternalSignerProxy(handler, result.pubkey)
|
||||||
|
|
||||||
|
// Update signer
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
// Store the signer in the secret storage
|
||||||
|
secretStore.set(
|
||||||
|
KEY_USER_SIGNER,
|
||||||
|
"nip55://${result.packageName}/${result.pubkey.toHex()}"
|
||||||
|
)
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(signerRequired = false, isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
showError("Notice: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isExternalSignerAvailable(): Boolean {
|
||||||
|
return externalSignerHandler?.isAvailable() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIdentity(
|
||||||
|
name: String,
|
||||||
|
bio: String?,
|
||||||
|
picture: ByteArray?,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_state.update { it.copy(isBusy = true) }
|
||||||
|
|
||||||
|
val keys = Keys.generate()
|
||||||
|
val secret = keys.secretKey().toBech32()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val avatarUrl = picture?.let {
|
||||||
|
mediaRepository.blossomUpload(nostr.signer.get(), it, contentType ?: "image/jpeg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create identity
|
||||||
|
nostr.profiles.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
|
||||||
|
|
||||||
|
// Persist the secret in the secret storage
|
||||||
|
secretStore.set(KEY_USER_SIGNER, secret)
|
||||||
|
|
||||||
|
// Update local states
|
||||||
|
_state.update { it.copy(isBusy = false, signerRequired = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
_state.update { it.copy(isBusy = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import su.reya.coop.repository.ErrorRepository
|
||||||
|
|
||||||
|
abstract class BaseViewModel : ViewModel() {
|
||||||
|
protected fun showError(message: String) {
|
||||||
|
ErrorRepository.showError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import rust.nostr.sdk.EventBuilder
|
||||||
|
import rust.nostr.sdk.EventId
|
||||||
|
import rust.nostr.sdk.Kind
|
||||||
|
import rust.nostr.sdk.KindStandard
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Tag
|
||||||
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.Room
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import su.reya.coop.roomId
|
||||||
|
|
||||||
|
data class ChatState(
|
||||||
|
val rooms: Set<Room> = emptySet(),
|
||||||
|
val isSyncing: Boolean = false,
|
||||||
|
val isPartialProcessedGiftWrap: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class ChatViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(ChatState())
|
||||||
|
val state = combine(
|
||||||
|
_state,
|
||||||
|
nostr.messages.messageSyncState
|
||||||
|
) { local, state -> local.copy(isSyncing = state.isSyncing) }.stateIn(
|
||||||
|
viewModelScope,
|
||||||
|
SharingStarted.WhileSubscribed(5000),
|
||||||
|
ChatState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
|
||||||
|
val sentReport = _sentReports.asSharedFlow()
|
||||||
|
|
||||||
|
val chatRooms = state.map { it.rooms }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptySet())
|
||||||
|
|
||||||
|
val isSyncing = state.map { it.isSyncing }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
val isPartialProcessedGiftWrap = state.map { it.isPartialProcessedGiftWrap }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Observe message sync progress
|
||||||
|
launch {
|
||||||
|
nostr.messages.messageSyncState.collect { syncState ->
|
||||||
|
// When at least some messages are processed, allow UI to show the list
|
||||||
|
if (syncState.processedCount > 0) {
|
||||||
|
_state.update { it.copy(isPartialProcessedGiftWrap = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh UI every 10 messages OR when sync is fully done
|
||||||
|
if (syncState.processedCount % 10 == 0 || !syncState.isSyncing) {
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe new messages
|
||||||
|
launch {
|
||||||
|
nostr.newEvents.collect { event ->
|
||||||
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _state.value.rooms.firstOrNull { it.id == roomId }
|
||||||
|
|
||||||
|
if (existingRoom == null) {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return@collect
|
||||||
|
val newRoom = Room.new(event, currentUser)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = (it.rooms + newRoom).sortedDescending().toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
_newEvents.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load of rooms
|
||||||
|
refreshChatRooms()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
|
if (to.isEmpty()) {
|
||||||
|
throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.value
|
||||||
|
?: throw IllegalStateException("User not signed in")
|
||||||
|
|
||||||
|
// Construct the rumor event
|
||||||
|
val rumor = EventBuilder(Kind.fromStd(KindStandard.PRIVATE_DIRECT_MESSAGE), "")
|
||||||
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
|
.finalizeUnsigned(currentUser)
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
val id = rumor.roomId()
|
||||||
|
val existingRoom = _state.value.rooms.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
// If the room already exists, return its ID
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return existingRoom.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a room from the rumor event
|
||||||
|
val room = Room.new(rumor, currentUser)
|
||||||
|
|
||||||
|
// Update the chat rooms state
|
||||||
|
_state.update { it.copy(rooms = (it.rooms + room).sortedDescending().toSet()) }
|
||||||
|
|
||||||
|
return room.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChatRoom(id: Long): Room? {
|
||||||
|
return _state.value.rooms.firstOrNull { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshChatRooms() {
|
||||||
|
try {
|
||||||
|
val rooms = nostr.messages.getChatRooms() ?: emptySet()
|
||||||
|
_state.update { currentState ->
|
||||||
|
val merged = currentState.rooms.associateBy { it.id }.toMutableMap()
|
||||||
|
// Add or update rooms from the database
|
||||||
|
rooms.forEach { room ->
|
||||||
|
merged[room.id] = room
|
||||||
|
}
|
||||||
|
// Return as a sorted set to maintain UI consistency
|
||||||
|
currentState.copy(rooms = merged.values.sortedDescending().toSet())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getChatRoomMessages(roomId: Long): List<UnsignedEvent> {
|
||||||
|
try {
|
||||||
|
return nostr.messages.getChatRoomMessages(roomId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatRoomConnect(roomId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
val members = room.members
|
||||||
|
|
||||||
|
nostr.messages.chatRoomConnect(members.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
showError("Message cannot be empty")
|
||||||
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found")
|
||||||
|
nostr.messages.sendMessage(
|
||||||
|
to = room.members,
|
||||||
|
content = message,
|
||||||
|
subject = room.subject,
|
||||||
|
replies = replies,
|
||||||
|
onRumorCreated = { event ->
|
||||||
|
updateRoomList(roomId, event)
|
||||||
|
viewModelScope.launch { _newEvents.emit(event) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendFileMessage(
|
||||||
|
roomId: Long,
|
||||||
|
file: ByteArray?,
|
||||||
|
contentType: String? = "image/jpeg",
|
||||||
|
replies: List<EventId> = emptyList()
|
||||||
|
) {
|
||||||
|
if (file == null) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val uri = mediaRepository.blossomUpload(nostr.signer.get(), file, contentType)
|
||||||
|
if (uri != null) sendMessage(roomId, uri, replies)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isMessageSent(id: EventId): Boolean {
|
||||||
|
val giftWrapId = nostr.messages.rumorMap[id]
|
||||||
|
|
||||||
|
if (giftWrapId != null) {
|
||||||
|
val isSent = nostr.messages.sentEvents[giftWrapId]?.isNotEmpty() ?: false
|
||||||
|
return isSent
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
|
_state.update { currentState ->
|
||||||
|
val updatedRooms = currentState.rooms.map { room ->
|
||||||
|
if (room.id == roomId) {
|
||||||
|
room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
|
createdAt = newMessage.createdAt()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
room
|
||||||
|
}
|
||||||
|
}.sortedDescending().toSet()
|
||||||
|
currentState.copy(rooms = updatedRooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
rooms = emptySet(),
|
||||||
|
isPartialProcessedGiftWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package su.reya.coop.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import rust.nostr.sdk.RelayMetadata
|
||||||
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import rust.nostr.sdk.Timestamp
|
||||||
|
import su.reya.coop.Profile
|
||||||
|
import su.reya.coop.nostr.Nostr
|
||||||
|
import su.reya.coop.repository.MediaRepository
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
data class NostrAppState(
|
||||||
|
val isBusy: Boolean = false,
|
||||||
|
val isRelayListEmpty: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
class NostrViewModel(private val nostr: Nostr) : BaseViewModel() {
|
||||||
|
private val mediaRepository = MediaRepository()
|
||||||
|
|
||||||
|
private val alwaysRunTasks = flow {
|
||||||
|
coroutineScope {
|
||||||
|
val observerJob = launch { runObserver() }
|
||||||
|
val batchingJob = launch { runMetadataBatching() }
|
||||||
|
try {
|
||||||
|
emit(Unit)
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
observerJob.cancel()
|
||||||
|
batchingJob.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _appState = MutableStateFlow(NostrAppState())
|
||||||
|
val appState: StateFlow<NostrAppState> =
|
||||||
|
combine(_appState, alwaysRunTasks) { state, _ -> state }.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = NostrAppState()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||||
|
val contactList = _contactList.asStateFlow()
|
||||||
|
|
||||||
|
private val profilesMutex = Mutex()
|
||||||
|
private val profiles = mutableMapOf<PublicKey, MutableStateFlow<Profile?>>()
|
||||||
|
private val metadataRequestChannel = Channel<PublicKey>(Channel.UNLIMITED)
|
||||||
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
|
val isBusy = appState.map { it.isBusy }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
val isRelayListEmpty = appState.map { it.isRelayListEmpty }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
val currentUserProfile = nostr.signer.publicKeyFlow
|
||||||
|
.flatMapLatest { pubkey ->
|
||||||
|
if (pubkey != null) getMetadata(pubkey) else flowOf(null)
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Automatically reconnect bootstrap relays
|
||||||
|
reconnect()
|
||||||
|
|
||||||
|
// Observe the signer state and verify the relay list
|
||||||
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
|
// Get all local stored metadata
|
||||||
|
getCacheMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnect() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
nostr.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runObserver() = coroutineScope {
|
||||||
|
// Observe contact list updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe metadata updates
|
||||||
|
launch {
|
||||||
|
nostr.profiles.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runMetadataBatching() = coroutineScope {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
val batch = mutableSetOf<PublicKey>()
|
||||||
|
val timeout = 500L // 500ms timeout for batching
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Get the first pubkey
|
||||||
|
val firstKey = metadataRequestChannel.receive()
|
||||||
|
batch.add(firstKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val lastFlushTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
while (batch.isNotEmpty()) {
|
||||||
|
// Get the next pubkey
|
||||||
|
val nextKey = withTimeoutOrNull(timeout.milliseconds) {
|
||||||
|
metadataRequestChannel.receive()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add the pubkey if it's not null
|
||||||
|
if (nextKey != null) batch.add(nextKey)
|
||||||
|
|
||||||
|
// Get current time
|
||||||
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
|
// Check if the batch is full or timeout has passed
|
||||||
|
if (batch.size >= 10 || (now - lastFlushTime) >= timeout || nextKey == null) {
|
||||||
|
val keysToRequest = batch.toList()
|
||||||
|
batch.clear()
|
||||||
|
|
||||||
|
nostr.profiles.fetchMetadataBatch(keysToRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCacheMetadata() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
nostr.profiles.getAllCacheMetadata().forEach { (pubkey, metadata) ->
|
||||||
|
// Update the metadata state
|
||||||
|
updateMetadata(pubkey, Profile(pubkey, metadata))
|
||||||
|
// Update seenPublicKeys to avoid duplicate requests
|
||||||
|
seenPublicKeys.add(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignerAndCheckRelays() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Wait until the client is ready
|
||||||
|
nostr.waitUntilInitialized()
|
||||||
|
|
||||||
|
// Wait until a signer is explicitly set (which updates publicKeyFlow)
|
||||||
|
val currentUser = nostr.signer.publicKeyFlow.filterNotNull().first()
|
||||||
|
|
||||||
|
// Get all metadata for the current user
|
||||||
|
nostr.profiles.getUserMetadata()
|
||||||
|
|
||||||
|
// Small delay to ensure all relays are connected
|
||||||
|
delay(2.seconds)
|
||||||
|
|
||||||
|
// Check if the relay list is empty
|
||||||
|
val relays = nostr.relays.getMsgRelays(currentUser)
|
||||||
|
if (relays.isEmpty()) _appState.update { it.copy(isRelayListEmpty = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestMetadata(pubkey: PublicKey) {
|
||||||
|
if (seenPublicKeys.add(pubkey)) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
metadataRequestChannel.send(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMetadata(pubkey: PublicKey, profile: Profile) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
profilesMutex.withLock {
|
||||||
|
profiles.getOrPut(pubkey) { MutableStateFlow(null) }.value = profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getMetadata(pubkey: PublicKey): StateFlow<Profile?> {
|
||||||
|
val flow = profiles.getOrPut(pubkey) { MutableStateFlow(null) }
|
||||||
|
if (flow.value == null) requestMetadata(pubkey)
|
||||||
|
|
||||||
|
return flow.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetInternalState() {
|
||||||
|
_contactList.value = emptySet()
|
||||||
|
_appState.update {
|
||||||
|
it.copy(
|
||||||
|
isRelayListEmpty = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissRelayWarning() {
|
||||||
|
_appState.update { it.copy(isRelayListEmpty = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateProfile(
|
||||||
|
name: String? = null,
|
||||||
|
bio: String? = null,
|
||||||
|
picture: ByteArray? = null,
|
||||||
|
contentType: String? = null
|
||||||
|
) {
|
||||||
|
_appState.update { it.copy(isBusy = true) }
|
||||||
|
try {
|
||||||
|
val avatarUrl =
|
||||||
|
picture?.let {
|
||||||
|
mediaRepository.blossomUpload(
|
||||||
|
nostr.signer.get(),
|
||||||
|
it,
|
||||||
|
contentType ?: "image/jpeg"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val newMetadata = nostr.profiles.updateProfile(name, bio, avatarUrl)
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
|
||||||
|
// Update the metadata state after successfully published
|
||||||
|
updateMetadata(currentUser, Profile(currentUser, newMetadata))
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
_appState.update { it.copy(isBusy = false) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refetchMsgRelays() {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: return
|
||||||
|
val relays = nostr.relays.fetchMsgRelays(currentUser)
|
||||||
|
|
||||||
|
if (relays.isNotEmpty()) dismissRelayWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun useDefaultMsgRelayList() {
|
||||||
|
try {
|
||||||
|
val defaultRelays = nostr.relays.getDefaultMsgRelayList()
|
||||||
|
nostr.relays.setMsgRelays(defaultRelays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun currentUserRelayList(): Map<RelayUrl, RelayMetadata?> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getRelayList(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addInboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.WRITE
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addOutboxRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays[relayUrl] = RelayMetadata.READ
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserRelayList().toMutableMap()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setRelaylist(relays)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun currentUserMsgRelayList(): List<RelayUrl> {
|
||||||
|
try {
|
||||||
|
val currentUser = nostr.signer.getPublicKeyAsync() ?: throw Exception("User not found")
|
||||||
|
return nostr.relays.getMsgRelays(currentUser)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.add(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeMsgRelay(relay: String) {
|
||||||
|
try {
|
||||||
|
val relayUrl = RelayUrl.parse(relay)
|
||||||
|
val relays = currentUserMsgRelayList().toMutableSet()
|
||||||
|
relays.remove(relayUrl)
|
||||||
|
|
||||||
|
nostr.relays.setMsgRelays(relays.toList())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun newContact(publicKey: PublicKey) {
|
||||||
|
if (publicKey in contactList.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value + publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it + publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addContact(address: String): Boolean {
|
||||||
|
val pubkey = try {
|
||||||
|
if (address.contains("@")) {
|
||||||
|
nostr.profiles.searchByAddress(address)
|
||||||
|
} else {
|
||||||
|
PublicKey.parse(address)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Invalid contact address: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return run {
|
||||||
|
newContact(pubkey)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContact(publicKey: PublicKey) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (publicKey !in contactList.value) return@launch
|
||||||
|
|
||||||
|
try {
|
||||||
|
val updated = contactList.value - publicKey
|
||||||
|
// Publish new event
|
||||||
|
nostr.profiles.setContactList(updated.toList())
|
||||||
|
// Optimistic local update
|
||||||
|
_contactList.update { it - publicKey }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByAddress(query: String): PublicKey? {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByAddress(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun searchByNostr(query: String): List<PublicKey> {
|
||||||
|
try {
|
||||||
|
return nostr.profiles.searchByNostr(query)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyActivity(pubkey: PublicKey): Timestamp? {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyActivity(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun verifyContact(pubkey: PublicKey): Boolean {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.verifyContact(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun mutualContacts(pubkey: PublicKey): Set<PublicKey> {
|
||||||
|
return try {
|
||||||
|
nostr.profiles.mutualContacts(pubkey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showError("Error: ${e.message}")
|
||||||
|
setOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
zapstore.yaml
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# SOURCE CONFIGURATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
repository: https://git.reya.su/reya/coop-mobile
|
||||||
|
release_source:
|
||||||
|
url: https://git.reya.su/reya/coop-mobile
|
||||||
|
type: gitea
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# APP METADATA
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
name: Coop
|
||||||
|
summary: Chat and Co-op Privately
|
||||||
|
license: GPLv3
|
||||||
|
website: https://reya.su/coop
|
||||||
|
|
||||||
|
description: |
|
||||||
|
Coop is a simple, cozy chat app built for Nostr. It’s designed for folks who want to stay connected without sacrificing their privacy or control over their data.
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Private & Secure: Enjoy end-to-end encrypted messaging that keeps your private chats truly private.
|
||||||
|
- Friendly Socializing: Connect with friends across the open Nostr network in a clean, easy-to-use interface.
|
||||||
|
- No Identity Required: Get started without a phone number. Stay anonymous, use a pseudonym, or even your real name.
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- messaging
|
||||||
|
- privacy
|
||||||
|
- material3
|
||||||
|
- nostr
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# MEDIA
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
icon: ./assets/icon.png
|
||||||
|
|
||||||
|
images:
|
||||||
|
- ./assets/screenshot1.png
|
||||||
|
- ./assets/screenshot2.png
|
||||||
|
- ./assets/screenshot3.png
|
||||||
|
- ./assets/screenshot4.png
|
||||||
|
- ./assets/screenshot5.png
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# RELEASE CONFIGURATION
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
release_notes: ./CHANGELOG.md
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# NOSTR-SPECIFIC
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
supported_nips:
|
||||||
|
- "01"
|
||||||
|
- "17"
|
||||||
|
- "46"
|
||||||
|
- "55"
|
||||||
|
- "59"
|
||||||
|
|
||||||
|
min_allowed_version_code: 100
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
# METADATA SOURCES
|
||||||
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
metadata_sources:
|
||||||
|
- playstore
|
||||||