Merge pull request 'chore: merge the develop branch into master' (#1) from develop into master
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
@@ -6,6 +5,7 @@ plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -19,6 +19,16 @@ kotlin {
|
||||
androidMain.dependencies {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation("androidx.navigation:navigation-compose:2.8.8")
|
||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
||||
implementation("androidx.datastore:datastore-preferences-core:1.2.1")
|
||||
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
|
||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||
implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
|
||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||
}
|
||||
commonMain.dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -9,14 +19,18 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name=".MainActivity">
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".NostrForegroundService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
BIN
composeApp/src/androidMain/composeResources/drawable/avatar.png
Normal file
BIN
composeApp/src/androidMain/composeResources/drawable/avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,32 @@
|
||||
<!--
|
||||
~ Copyright (C) 2026 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="750dp"
|
||||
android:height="750dp"
|
||||
android:viewportWidth="750"
|
||||
android:viewportHeight="750">
|
||||
<path
|
||||
android:pathData="M353.02,681C488.9,681 599.05,570.85 599.05,434.98C599.05,422.63 589.03,412.61 576.68,412.61H129.37C117.01,412.61 107,422.63 107,434.98C107,570.85 217.15,681 353.02,681Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M461.72,151.67L317.71,156.11C317.71,156.11 307.55,142.16 308.32,132.29C309.29,119.83 317.72,109.73 328.88,105.93C328.22,103.04 327.53,100.13 327.77,97.03C329.07,80.3 343.74,67.8 360.51,69.09C370.4,69.85 378.36,75.51 383.33,83.23C389.44,76.36 398.21,72.01 408.1,72.77C423.68,73.97 435.15,86.83 435.69,102.04C437.83,101.74 439.89,101.08 442.14,101.26C458.92,102.55 471.44,117.15 470.14,133.88C469.51,142.02 461.72,151.67 461.72,151.67Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M150.95,389.09C150.95,253.21 261.1,143.06 396.98,143.06C532.84,143.06 643,253.21 643,389.09C643,401.44 632.98,411.45 620.63,411.45H173.32C160.96,411.45 150.95,401.44 150.95,389.09ZM505.67,259.28C504.32,258.9 502.97,258.71 501.6,258.71C500.23,258.71 498.88,258.9 497.54,259.28C496.19,259.66 494.89,260.23 493.62,260.98C492.36,261.73 491.16,262.65 490.02,263.74C488.88,264.83 487.83,266.07 486.86,267.45C485.89,268.84 485.03,270.35 484.27,271.98C483.51,273.61 482.87,275.33 482.34,277.14C481.82,278.95 481.43,280.82 481.16,282.74C480.89,284.66 480.76,286.6 480.76,288.57C480.76,290.53 480.89,292.47 481.16,294.39C481.43,296.31 481.82,298.17 482.34,299.99C482.87,301.8 483.51,303.52 484.27,305.15C485.03,306.78 485.89,308.29 486.86,309.67C487.83,311.06 488.88,312.3 490.02,313.39C491.16,314.48 492.36,315.4 493.62,316.14C494.89,316.89 496.19,317.46 497.54,317.85C498.88,318.23 500.23,318.42 501.6,318.42C502.97,318.42 504.32,318.23 505.67,317.85C507.01,317.46 508.31,316.89 509.57,316.14C510.84,315.4 512.05,314.48 513.18,313.39C514.32,312.3 515.38,311.06 516.34,309.67C517.31,308.29 518.17,306.78 518.93,305.15C519.7,303.52 520.33,301.8 520.86,299.99C521.38,298.17 521.78,296.31 522.04,294.39C522.31,292.47 522.45,290.53 522.45,288.57C522.45,286.6 522.31,284.66 522.04,282.74C521.78,280.82 521.38,278.95 520.86,277.14C520.33,275.33 519.7,273.61 518.93,271.98C518.17,270.35 517.31,268.84 516.34,267.45C515.38,266.07 514.32,264.83 513.18,263.74C512.05,262.65 510.84,261.73 509.57,260.98C508.31,260.23 507.01,259.66 505.67,259.28Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillType="evenOdd"/>
|
||||
</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="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z" />
|
||||
</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="M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z" />
|
||||
</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="M234,684Q285,645 348,622.5Q411,600 480,600Q549,600 612,622.5Q675,645 726,684Q761,643 780.5,591Q800,539 800,480Q800,347 706.5,253.5Q613,160 480,160Q347,160 253.5,253.5Q160,347 160,480Q160,539 179.5,591Q199,643 234,684ZM380.5,479.5Q340,439 340,380Q340,321 380.5,280.5Q421,240 480,240Q539,240 579.5,280.5Q620,321 620,380Q620,439 579.5,479.5Q539,520 480,520Q421,520 380.5,479.5ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM580,784.5Q627,769 666,740Q627,711 580,695.5Q533,680 480,680Q427,680 380,695.5Q333,711 294,740Q333,769 380,784.5Q427,800 480,800Q533,800 580,784.5ZM523,423Q540,406 540,380Q540,354 523,337Q506,320 480,320Q454,320 437,337Q420,354 420,380Q420,406 437,423Q454,440 480,440Q506,440 523,423ZM480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380Q480,380 480,380ZM480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Q480,740 480,740Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,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="M120,800L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,403Q750,401 740,400.5Q730,400 720,400Q710,400 700,400.5Q690,401 680,403L680,200Q680,200 680,200Q680,200 680,200L200,200Q200,200 200,200Q200,200 200,200L200,600L483,600Q481,610 480.5,620Q480,630 480,640Q480,650 480.5,660Q481,670 483,680L240,680L120,800ZM280,360L600,360L600,280L280,280L280,360ZM280,520L480,520L480,440L280,440L280,520ZM680,800L680,680L560,680L560,600L680,600L680,480L760,480L760,600L880,600L880,680L760,680L760,800L680,800ZM200,600L200,600L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,403Q200,453 200,501.5Q200,550 200,600Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
|
||||
</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="M520,840L520,760L600,760L600,840L520,840ZM440,760L440,560L520,560L520,760L440,760ZM760,640L760,480L840,480L840,640L760,640ZM680,480L680,400L760,400L760,480L680,480ZM200,560L200,480L280,480L280,560L200,560ZM120,480L120,400L200,400L200,480L120,480ZM480,200L480,120L560,120L560,200L480,200ZM180,300L300,300L300,180L180,180L180,300ZM120,360L120,120L360,120L360,360L120,360ZM180,780L300,780L300,660L180,660L180,780ZM120,840L120,600L360,600L360,840L120,840ZM660,300L780,300L780,180L660,180L660,300ZM600,360L600,120L840,120L840,360L600,360ZM680,840L680,720L600,720L600,640L760,640L760,760L840,760L840,840L680,840ZM520,560L520,480L680,480L680,560L520,560ZM360,560L360,480L280,480L280,400L520,400L520,480L440,480L440,560L360,560ZM400,360L400,200L480,200L480,280L560,280L560,360L400,360ZM210,270L210,210L270,210L270,270L210,270ZM210,750L210,690L270,690L270,750L210,750ZM690,270L690,210L750,210L750,270L690,270Z" />
|
||||
</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,280L80,80L280,80L280,160L160,160L160,280L80,280ZM80,880L80,680L160,680L160,800L280,800L280,880L80,880ZM680,880L680,800L800,800L800,680L880,680L880,880L680,880ZM800,280L800,160L680,160L680,80L880,80L880,280L800,280ZM700,700L760,700L760,760L700,760L700,700ZM700,580L760,580L760,640L700,640L700,580ZM640,640L700,640L700,700L640,700L640,640ZM580,700L640,700L640,760L580,760L580,700ZM520,640L580,640L580,700L520,700L520,640ZM640,520L700,520L700,580L640,580L640,520ZM580,580L640,580L640,640L580,640L580,580ZM520,520L580,520L580,580L520,580L520,520ZM760,200L760,440L520,440L520,200L760,200ZM440,520L440,760L200,760L200,520L440,520ZM440,200L440,440L200,440L200,200L440,200ZM380,700L380,580L260,580L260,700L380,700ZM380,380L380,260L260,260L260,380L380,380ZM700,380L700,260L580,260L580,380L700,380Z" />
|
||||
</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="M784,840L532,588Q502,612 463,626Q424,640 380,640Q271,640 195.5,564.5Q120,489 120,380Q120,271 195.5,195.5Q271,120 380,120Q489,120 564.5,195.5Q640,271 640,380Q640,424 626,463Q612,502 588,532L840,784L784,840ZM380,560Q455,560 507.5,507.5Q560,455 560,380Q560,305 507.5,252.5Q455,200 380,200Q305,200 252.5,252.5Q200,305 200,380Q200,455 252.5,507.5Q305,560 380,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="M440,800L440,313L216,537L160,480L480,160L800,480L744,537L520,313L520,800L440,800Z" />
|
||||
</vector>
|
||||
@@ -1,47 +1,252 @@
|
||||
package su.reya.coop
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.safeContentPadding
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.expressiveLightColorScheme
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.toRoute
|
||||
import kotlinx.coroutines.launch
|
||||
import su.reya.coop.coop.storage.SecretStore
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
import su.reya.coop.screens.MyQrScreen
|
||||
import su.reya.coop.screens.NewChatScreen
|
||||
import su.reya.coop.screens.NewIdentityScreen
|
||||
import su.reya.coop.screens.OnboardingScreen
|
||||
import su.reya.coop.screens.RelayScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.compose_multiplatform
|
||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
}
|
||||
|
||||
val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
error("No SnackbarHostState provided")
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
||||
error("No NavController provided")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@Preview
|
||||
fun App() {
|
||||
MaterialTheme {
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Initialize Nostr View Model and Secret Store
|
||||
val secretStore = remember { SecretStore(context) }
|
||||
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
|
||||
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
|
||||
darkMode -> darkColorScheme()
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides viewModel,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavController provides navController,
|
||||
) {
|
||||
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
LaunchedEffect(emptySecret) {
|
||||
// Navigate to the home screen if the secret is already set
|
||||
if (emptySecret == false) {
|
||||
navController.navigate(Screen.Home) {
|
||||
popUpTo(Screen.Onboarding) { inclusive = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
.safeContentPadding()
|
||||
.fillMaxSize(),
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.5f)
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Button(onClick = { showContent = !showContent }) {
|
||||
Text("Click me!")
|
||||
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()
|
||||
}
|
||||
AnimatedVisibility(showContent) {
|
||||
val greeting = remember { Greeting().greet() }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
) {
|
||||
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
||||
Text("Compose: $greeting")
|
||||
Text(
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
||||
) {
|
||||
composable<Screen.Onboarding> { backStackEntry ->
|
||||
OnboardingScreen(
|
||||
onOpenImport = { navController.navigate(Screen.Import) },
|
||||
onOpenNew = { navController.navigate(Screen.NewIdentity) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Import> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
ImportScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { secret ->
|
||||
viewModel.importIdentity(secret)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.NewIdentity> { backStackEntry ->
|
||||
val isCreating by viewModel.isCreating.collectAsState()
|
||||
|
||||
NewIdentityScreen(
|
||||
isLoading = isCreating,
|
||||
onBack = { navController.popBackStack() },
|
||||
onSave = { name, bio, uri ->
|
||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
||||
val picture = uri?.let {
|
||||
context.contentResolver.openInputStream(it)?.use { input ->
|
||||
input.readBytes()
|
||||
}
|
||||
}
|
||||
viewModel.createIdentity(name, bio, picture, contentType)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable<Screen.Home> { backStackEntry ->
|
||||
HomeScreen(
|
||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Chat> { backStackEntry ->
|
||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
||||
ChatScreen(
|
||||
id = chat.id,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.NewChat> { backStackEntry ->
|
||||
NewChatScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Scan> { backStackEntry ->
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.MyQr> { backStackEntry ->
|
||||
MyQrScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Relay> { backStackEntry ->
|
||||
RelayScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val intent = Intent(this, NostrForegroundService::class.java)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(intent)
|
||||
} else {
|
||||
startService(intent)
|
||||
}
|
||||
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun AppAndroidPreview() {
|
||||
App()
|
||||
}
|
||||
32
composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
Normal file
32
composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface Screen {
|
||||
@Serializable
|
||||
data object Home : Screen
|
||||
|
||||
@Serializable
|
||||
data class Chat(val id: Long) : Screen
|
||||
|
||||
@Serializable
|
||||
data object NewChat : Screen
|
||||
|
||||
@Serializable
|
||||
data object Onboarding : Screen
|
||||
|
||||
@Serializable
|
||||
data object Import : Screen
|
||||
|
||||
@Serializable
|
||||
data object NewIdentity : Screen
|
||||
|
||||
@Serializable
|
||||
data object Scan : Screen
|
||||
|
||||
@Serializable
|
||||
data object MyQr : Screen
|
||||
|
||||
@Serializable
|
||||
data object Relay : Screen
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
class NostrForegroundService : Service() {
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val nostr = NostrManager.instance
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun isUserInApp(): Boolean {
|
||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
createNotificationChannel()
|
||||
val notification = createNotification("Connecting to Nostr...")
|
||||
startForeground(1, notification)
|
||||
|
||||
serviceScope.launch {
|
||||
try {
|
||||
val dbDir = File(filesDir, "nostr")
|
||||
dbDir.mkdirs()
|
||||
// Initialize Nostr client
|
||||
nostr.init(dbDir.absolutePath)
|
||||
// Connect to bootstrap relays
|
||||
nostr.connectBootstrapRelays()
|
||||
// Handle notifications
|
||||
nostr.handleLiteNotifications { event ->
|
||||
if (!isUserInApp()) {
|
||||
showNewMessageNotification(event.content())
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Failed to start Nostr in background: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun createNotificationChannel() {
|
||||
val channel = NotificationChannel(
|
||||
"nostr_service",
|
||||
"Nostr Background Service",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
)
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun createNotification(content: String): Notification {
|
||||
return NotificationCompat.Builder(this, "nostr_service")
|
||||
.setContentTitle("Coop")
|
||||
.setContentText(content)
|
||||
.setSmallIcon(android.R.drawable.ic_menu_send)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun showNewMessageNotification(message: String) {
|
||||
val notification = NotificationCompat.Builder(this, "nostr_service")
|
||||
.setContentTitle("New Message")
|
||||
.setContentText(message)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceScope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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,441 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.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.pulltorefresh.PullToRefreshBox
|
||||
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
|
||||
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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.platform.LocalClipboard
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_new_chat
|
||||
import coop.composeapp.generated.resources.ic_qr
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
import su.reya.coop.Room
|
||||
import su.reya.coop.Screen
|
||||
import su.reya.coop.ago
|
||||
import su.reya.coop.shared.Avatar
|
||||
import su.reya.coop.shared.displayNameFlow
|
||||
import su.reya.coop.shared.pictureFlow
|
||||
import su.reya.coop.short
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
onOpenChat: (Long) -> Unit,
|
||||
onNewChat: () -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val navController = LocalNavController.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
val currentUserProfile = viewModel.getMetadata(currentUser) ?: return
|
||||
|
||||
val userProfile by currentUserProfile.collectAsState(initial = null)
|
||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
||||
val isPartialProcessedGiftWrap by viewModel.isPartialProcessedGiftWrap.collectAsState(initial = false)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val listState = rememberLazyListState()
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
|
||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||
val qrResult by savedStateHandle
|
||||
?.getStateFlow<String?>("qr_result", null)
|
||||
?.collectAsState()
|
||||
?: remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getChatRooms()
|
||||
}
|
||||
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
}
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
|
||||
// Clear the nav state
|
||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "Coop",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// QR Scanner
|
||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
// User
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
size = 32.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
if (!expandedFab) {
|
||||
PlainTooltip { Text("New Chat") }
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onNewChat,
|
||||
expanded = expandedFab,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_new_chat),
|
||||
contentDescription = "New Chat"
|
||||
)
|
||||
},
|
||||
text = { Text("New Chat") },
|
||||
)
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
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
|
||||
viewModel.refreshChatRooms()
|
||||
isRefreshing = false
|
||||
}
|
||||
},
|
||||
indicator = {
|
||||
PullToRefreshDefaults.LoadingIndicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = isRefreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (!isPartialProcessedGiftWrap) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LoadingIndicator()
|
||||
}
|
||||
} else if (chatRooms.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "No chats yet",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Your conversations will appear here.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
||||
ChatRoom(
|
||||
room = room,
|
||||
onClick = { onOpenChat(room.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
val pubkey = viewModel.currentUser()
|
||||
val shortPubkey = pubkey?.short() ?: "Not available"
|
||||
|
||||
val userName =
|
||||
userProfile?.asRecord()?.displayName
|
||||
?: userProfile?.asRecord()?.name
|
||||
?: "No name"
|
||||
|
||||
val dismissAndRun: (suspend () -> Unit) -> Unit = { action ->
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(84.dp)
|
||||
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = userProfile?.asRecord()?.picture,
|
||||
description = userProfile?.asRecord()?.displayName,
|
||||
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = userName,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
dismissAndRun { navController.navigate(Screen.MyQr) }
|
||||
},
|
||||
) {
|
||||
Text(text = shortPubkey)
|
||||
}
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
sheetState.hide()
|
||||
showBottomSheet = false
|
||||
navController.navigate(Screen.MyQr)
|
||||
}
|
||||
},
|
||||
shape = MaterialShapes.Square.toShape()
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_qr),
|
||||
contentDescription = "My QR"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
BottomMenuList(onDismiss = dismissAndRun)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ChatRoom(room: Room, onClick: () -> Unit) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||
|
||||
ListItem(
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
leadingContent = {
|
||||
Avatar(picture = picture, description = displayName)
|
||||
},
|
||||
headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = room.createdAt.ago(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
if (!room.lastMessage.isNullOrBlank()) {
|
||||
Text(
|
||||
text = room.lastMessage!!,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BottomMenuList(
|
||||
onDismiss: (suspend () -> Unit) -> Unit
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val defaultMenuList = listOf(
|
||||
"Relay Management" to { navController.navigate(Screen.Relay) },
|
||||
"Spams & Blocks" to { },
|
||||
"Contacts" to { },
|
||||
"Settings" to { }
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
defaultMenuList.forEachIndexed { index, (title, action) ->
|
||||
SegmentedListItem(
|
||||
onClick = { onDismiss { action() } },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = defaultMenuList.size
|
||||
),
|
||||
content = { Text(text = title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { viewModel.logout() },
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
contentColor = MaterialTheme.colorScheme.onError
|
||||
)
|
||||
) {
|
||||
Text(text = "Logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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.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.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.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavController
|
||||
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 ImportScreen(
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onSave: (secret: String) -> Unit
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navController = LocalNavController.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var secret by remember { mutableStateOf("") }
|
||||
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
|
||||
val metadata by remember(pubkey) {
|
||||
if (pubkey != null) {
|
||||
viewModel.getMetadata(pubkey!!)
|
||||
} else {
|
||||
MutableStateFlow(null)
|
||||
}
|
||||
}.collectAsState(null)
|
||||
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||
val picture = profile?.picture
|
||||
|
||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||
val qrResult by savedStateHandle
|
||||
?.getStateFlow<String?>("qr_result", null)
|
||||
?.collectAsState()
|
||||
?: remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { result ->
|
||||
runCatching {
|
||||
if (result.startsWith("nsec")) {
|
||||
Keys.parse(result)
|
||||
} else if (result.startsWith("bunker://")) {
|
||||
NostrConnectUri.parse(result)
|
||||
} else {
|
||||
throw IllegalArgumentException("Invalid secret: $result")
|
||||
}
|
||||
}
|
||||
.onSuccess { it -> secret = result }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
// Clear the nav state
|
||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Import",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
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()),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
shape = MaterialShapes.Pentagon.toShape(),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = displayName,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
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 = "Enter your Secret Key or Bunker URI:",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = secret,
|
||||
onValueChange = { secret = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
visualTransformation = PasswordVisualTransformation('*'),
|
||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (secret.isEmpty()) {
|
||||
Text(
|
||||
"bunker://",
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {
|
||||
if (pubkey == null) {
|
||||
scope.launch {
|
||||
viewModel.verifyIdentity(secret).let { pubkey = it }
|
||||
}
|
||||
} else {
|
||||
onSave(secret)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = secret.isNotBlank() && !isLoading,
|
||||
) {
|
||||
if (isLoading) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = if (pubkey == null) "Verify" else "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@Composable
|
||||
fun MyQrScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val currentUser = viewModel.currentUser() ?: return
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "My QR",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Image(
|
||||
painter = rememberQrCodePainter(currentUser.toBech32()),
|
||||
contentDescription = "My QR"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.InputChip
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.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.CompositionLocalProvider
|
||||
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.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_arrow_next
|
||||
import coop.composeapp.generated.resources.ic_close_small
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.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 NewChatScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val navController = LocalNavController.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
||||
val createGroup = remember { mutableStateOf(false) }
|
||||
val searchResults = remember { mutableStateListOf<PublicKey>() }
|
||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||
var query by remember { mutableStateOf("") }
|
||||
|
||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
||||
val qrResult by savedStateHandle
|
||||
?.getStateFlow<String?>("qr_result", null)
|
||||
?.collectAsState()
|
||||
?: remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(query) {
|
||||
if (query.length >= 3) {
|
||||
delay(500) // 500ms debounce
|
||||
|
||||
if (query.startsWith("npub1")) {
|
||||
val pubkey = try {
|
||||
PublicKey.parse(query)
|
||||
} catch (e: Exception) {
|
||||
println("Failed to parse npub: ${e.message}")
|
||||
null
|
||||
}
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else if (query.contains("@")) {
|
||||
val pubkey = viewModel.searchByAddress(query)
|
||||
if (pubkey != null) {
|
||||
selectedReceivers.add(pubkey)
|
||||
}
|
||||
} else {
|
||||
val results = viewModel.searchByNostr(query)
|
||||
searchResults.clear()
|
||||
searchResults.addAll(results)
|
||||
}
|
||||
|
||||
query = ""
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(qrResult) {
|
||||
qrResult?.let { result ->
|
||||
runCatching { PublicKey.parse(result) }
|
||||
.onSuccess { pubkey -> selectedReceivers.add(pubkey) }
|
||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||
// Clear the nav state
|
||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = if (createGroup.value) "New group chat" else "New chat",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (selectedReceivers.isNotEmpty()) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
|
||||
TooltipAnchorPosition.Above,
|
||||
spacingBetweenTooltipAndAnchor = 8.dp,
|
||||
),
|
||||
tooltip = {
|
||||
PlainTooltip { Text("Next") }
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = {
|
||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
expanded = false,
|
||||
icon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_next),
|
||||
contentDescription = "Next"
|
||||
)
|
||||
},
|
||||
text = { Text("Next") },
|
||||
containerColor = MaterialTheme.colorScheme.tertiary,
|
||||
contentColor = MaterialTheme.colorScheme.onTertiary,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Text(
|
||||
text = "To:",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
selectedReceivers.forEach { receiver ->
|
||||
ReceiverChip(
|
||||
pubkey = receiver,
|
||||
onRemove = { selectedReceivers.remove(receiver) }
|
||||
)
|
||||
}
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
"Type a npub or address",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
if (searchResults.isNotEmpty()) {
|
||||
ContactList(
|
||||
items = searchResults,
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
} else {
|
||||
ContactList(
|
||||
title = "Contacts",
|
||||
items = contactList.toList(),
|
||||
selectedReceivers = selectedReceivers,
|
||||
onContactClick = { pubkey ->
|
||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||
navController.navigate(Screen.Chat(roomId))
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReceiverChip(
|
||||
pubkey: PublicKey,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
|
||||
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
|
||||
InputChip(
|
||||
selected = true,
|
||||
onClick = onRemove,
|
||||
label = {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
)
|
||||
},
|
||||
avatar = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
size = 24.dp
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_close_small),
|
||||
contentDescription = "Close"
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ContactList(
|
||||
title: String? = null,
|
||||
items: List<PublicKey>,
|
||||
selectedReceivers: SnapshotStateList<PublicKey>,
|
||||
onContactClick: (PublicKey) -> Unit
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
}
|
||||
items.forEachIndexed { index, item ->
|
||||
ContactListItem(
|
||||
pubkey = item,
|
||||
index = index,
|
||||
total = items.size,
|
||||
isSelected = selectedReceivers.contains(item),
|
||||
onClick = { onContactClick(item) },
|
||||
onLongClick = {
|
||||
if (!selectedReceivers.contains(item)) {
|
||||
selectedReceivers.add(item)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun ContactListItem(
|
||||
pubkey: PublicKey,
|
||||
index: Int,
|
||||
total: Int = 0,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||
val metadata by metadataFlow.collectAsState(initial = null)
|
||||
|
||||
val profile = metadata?.asRecord()
|
||||
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
|
||||
val picture = profile?.picture
|
||||
|
||||
SegmentedListItem(
|
||||
selected = isSelected,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = total
|
||||
),
|
||||
leadingContent = {
|
||||
Avatar(
|
||||
picture = picture,
|
||||
description = displayName,
|
||||
size = 36.dp
|
||||
)
|
||||
},
|
||||
supportingContent = { Text(text = pubkey.short()) },
|
||||
content = {
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import coop.composeapp.generated.resources.ic_plus
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun NewIdentityScreen(
|
||||
isLoading: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
var name by remember { mutableStateOf("") }
|
||||
var bio by remember { mutableStateOf("") }
|
||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
picture = uri
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Create a new identity",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
)
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(top = innerPadding.calculateTopPadding()),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(120.dp)
|
||||
.clip(MaterialShapes.Pentagon.toShape())
|
||||
.clickable { launcher.launch("image/*") },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (picture != null) {
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = "Profile picture",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_plus),
|
||||
contentDescription = "Pick avatar",
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "What others should call you?",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 1,
|
||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (name.isEmpty()) {
|
||||
Text(
|
||||
"Alice",
|
||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = "Your bio (optional)",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
BasicTextField(
|
||||
value = bio,
|
||||
onValueChange = { bio = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxLines = 3,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.primaryFixed,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (bio.isEmpty()) {
|
||||
Text(
|
||||
"I love cat",
|
||||
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = {
|
||||
onSave(name, bio, picture)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(ButtonDefaults.MediumContainerHeight),
|
||||
enabled = name.isNotBlank() && !isLoading,
|
||||
) {
|
||||
if (isLoading) {
|
||||
LoadingIndicator()
|
||||
} else {
|
||||
Text(
|
||||
text = "Continue",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.drawscope.rotate
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.coop
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val logoPainter = painterResource(Res.drawable.coop)
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
content = { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
||||
) {
|
||||
LogoRepeatingBackground(
|
||||
painter = logoPainter,
|
||||
logosPerRow = 6,
|
||||
rotationDegrees = -25f,
|
||||
horizontalOffset = 0.5f
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// TODO: Add headline
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = innerPadding.calculateBottomPadding()),
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
|
||||
) {
|
||||
Button(
|
||||
onClick = onOpenNew,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.size(ButtonDefaults.LargeContainerHeight),
|
||||
) {
|
||||
Text(
|
||||
text = "Start messaging",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
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
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = "Import identity",
|
||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogoRepeatingBackground(
|
||||
painter: Painter,
|
||||
logosPerRow: Int,
|
||||
rotationDegrees: Float = 0f,
|
||||
horizontalOffset: Float = 0.5f
|
||||
) {
|
||||
val tintColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val logoSize = canvasWidth / logosPerRow
|
||||
|
||||
val offsetX = logoSize * horizontalOffset
|
||||
val extraPadding = 2
|
||||
|
||||
val cols = logosPerRow + (extraPadding * 2)
|
||||
val rows = (canvasHeight / logoSize).toInt() + 1
|
||||
|
||||
for (row in 0 until rows) {
|
||||
for (col in -extraPadding until cols) {
|
||||
val px = (col * logoSize) - offsetX
|
||||
val py = row * logoSize
|
||||
|
||||
rotate(
|
||||
degrees = rotationDegrees,
|
||||
pivot = Offset(
|
||||
px + logoSize / 2,
|
||||
py + logoSize / 2
|
||||
)
|
||||
) {
|
||||
translate(left = px, top = py) {
|
||||
with(painter) {
|
||||
draw(
|
||||
size = Size(logoSize, logoSize),
|
||||
alpha = 0.1f,
|
||||
colorFilter = ColorFilter.tint(
|
||||
tintColor
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.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.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_arrow_back
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import rust.nostr.sdk.RelayMetadata
|
||||
import rust.nostr.sdk.RelayUrl
|
||||
import su.reya.coop.LocalNostrViewModel
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun RelayScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
|
||||
val msgRelayList = remember { mutableStateListOf<RelayUrl>() }
|
||||
val relayList = remember { mutableStateMapOf<RelayUrl, RelayMetadata?>() }
|
||||
|
||||
val inboxRelays by remember {
|
||||
derivedStateOf {
|
||||
relayList.filter { it.value == RelayMetadata.READ || it.value == null }.keys.toList()
|
||||
}
|
||||
}
|
||||
|
||||
val outboxRelays by remember {
|
||||
derivedStateOf {
|
||||
relayList.filter { it.value == RelayMetadata.WRITE || it.value == null }.keys.toList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
relayList.putAll(viewModel.currentUserRelayList())
|
||||
msgRelayList.addAll(viewModel.currentUserMsgRelayList())
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = "Relay Management",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Messaging Relay List",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
if (msgRelayList.isNotEmpty()) {
|
||||
msgRelayList.forEachIndexed { index, relayUrl ->
|
||||
SegmentedListItem(
|
||||
onClick = { },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = msgRelayList.size
|
||||
),
|
||||
content = { Text(text = relayUrl.toString()) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No relays configured",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Inbox Relays",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
if (inboxRelays.isNotEmpty()) {
|
||||
inboxRelays.forEachIndexed { index, relayUrl ->
|
||||
SegmentedListItem(
|
||||
onClick = { },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = inboxRelays.size
|
||||
),
|
||||
content = { Text(text = relayUrl.toString()) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No relays configured",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Outbox Relays",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||
) {
|
||||
if (outboxRelays.isNotEmpty()) {
|
||||
outboxRelays.forEachIndexed { index, relayUrl ->
|
||||
SegmentedListItem(
|
||||
onClick = { },
|
||||
shapes = ListItemDefaults.segmentedShapes(
|
||||
index = index,
|
||||
count = outboxRelays.size
|
||||
),
|
||||
content = { Text(text = relayUrl.toString()) },
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No relays configured",
|
||||
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package su.reya.coop.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
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 org.jetbrains.compose.resources.painterResource
|
||||
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
||||
import org.publicvalue.multiplatform.qrcode.CodeType
|
||||
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
||||
import su.reya.coop.LocalNavController
|
||||
import su.reya.coop.LocalSnackbarHostState
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun ScanScreen(
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
|
||||
val onResult: (String) -> Unit = { result ->
|
||||
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Scan QR",
|
||||
style = MaterialTheme.typography.titleMediumEmphasized
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = Color.White,
|
||||
navigationIconContentColor = Color.White,
|
||||
)
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
ScannerWithPermissions(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onScanned = {
|
||||
println("Scanned: $it");
|
||||
onResult(it)
|
||||
true
|
||||
},
|
||||
types = listOf(CodeType.QR),
|
||||
cameraPosition = CameraPosition.BACK,
|
||||
enableTorch = false
|
||||
)
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val scannerSize = 250.dp.toPx()
|
||||
val left = (size.width - scannerSize) / 2
|
||||
val top = (size.height - scannerSize) / 2
|
||||
drawRect(color = Color.Black.copy(alpha = 0.6f))
|
||||
drawRect(
|
||||
color = Color.Transparent,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(scannerSize, scannerSize),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(250.dp)
|
||||
.align(Alignment.Center)
|
||||
.border(2.dp, Color.White, RoundedCornerShape(12.dp))
|
||||
)
|
||||
Text(
|
||||
text = "Scan a Nostr address",
|
||||
style = MaterialTheme.typography.titleSmallEmphasized,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package su.reya.coop.shared
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.avatar
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
@Composable
|
||||
fun Avatar(
|
||||
picture: String?,
|
||||
description: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 48.dp,
|
||||
shape: Shape = CircleShape
|
||||
) {
|
||||
val placeholder = painterResource(Res.drawable.avatar)
|
||||
|
||||
AsyncImage(
|
||||
model = picture,
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Crop,
|
||||
fallback = placeholder,
|
||||
error = placeholder,
|
||||
placeholder = placeholder
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package su.reya.coop.coop.storage
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
data class SecretEntry(
|
||||
val encrypted: String,
|
||||
val iv: String
|
||||
)
|
||||
|
||||
class SecretCrypto {
|
||||
private val keyAlias = "coop"
|
||||
private val keyStoreType = "AndroidKeyStore"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
|
||||
fun encrypt(content: String): SecretEntry {
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||
|
||||
// Encrypt content
|
||||
val encrypted = cipher.doFinal(content.toByteArray())
|
||||
val iv = cipher.iv
|
||||
|
||||
return SecretEntry(
|
||||
encrypted = Base64.encodeToString(encrypted, Base64.NO_WRAP),
|
||||
iv = Base64.encodeToString(iv, Base64.NO_WRAP)
|
||||
)
|
||||
}
|
||||
|
||||
fun decrypt(entry: SecretEntry): String {
|
||||
val encrypted = Base64.decode(entry.encrypted, Base64.NO_WRAP)
|
||||
val iv = Base64.decode(entry.iv, Base64.NO_WRAP)
|
||||
|
||||
// Initialize cipher
|
||||
val cipher = Cipher.getInstance(transformation)
|
||||
val spec = GCMParameterSpec(128, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), spec)
|
||||
|
||||
// Decrypt content
|
||||
val plaintext = cipher.doFinal(encrypted)
|
||||
|
||||
return String(plaintext, StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(keyStoreType).apply { load(null) }
|
||||
val existingKey = keyStore.getKey(keyAlias, null)
|
||||
|
||||
// Return existing key if available
|
||||
if (existingKey is SecretKey) return existingKey
|
||||
|
||||
// Construct a new key generator
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, keyStoreType)
|
||||
|
||||
// Initialize key generation parameters
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
|
||||
// Generate a new key
|
||||
keyGenerator.init(spec)
|
||||
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package su.reya.coop.coop.storage
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
|
||||
private val Context.dataStore by preferencesDataStore("secret_store")
|
||||
|
||||
class SecretStore(private val context: Context) : SecretStorage {
|
||||
private val crypto = SecretCrypto()
|
||||
|
||||
override suspend fun set(key: String, value: String) {
|
||||
val entry = crypto.encrypt(value)
|
||||
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[stringPreferencesKey("${key}_encrypted")] = entry.encrypted
|
||||
prefs[stringPreferencesKey("${key}_iv")] = entry.iv
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(key: String): String? {
|
||||
val prefs = context.dataStore.data.first()
|
||||
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
|
||||
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
|
||||
|
||||
return crypto.decrypt(SecretEntry(encrypted, iv))
|
||||
}
|
||||
|
||||
override suspend fun clear(key: String) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs.remove(stringPreferencesKey("${key}_encrypted"))
|
||||
prefs.remove(stringPreferencesKey("${key}_iv"))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun has(key: String): Boolean {
|
||||
val prefs = context.dataStore.data.first()
|
||||
return prefs[stringPreferencesKey("${key}_encrypted")] != null
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,13 @@ org.gradle.caching=true
|
||||
#Android
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
android.defaults.buildfeatures.resvalues=true
|
||||
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
|
||||
android.enableAppCompileTimeRClass=false
|
||||
android.usesSdkInManifest.disallowed=false
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.r8.optimizedResourceShrinking=false
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.13.2"
|
||||
agp = "9.2.1"
|
||||
android-compileSdk = "36"
|
||||
android-minSdk = "24"
|
||||
android-targetSdk = "36"
|
||||
@@ -8,11 +8,14 @@ androidx-appcompat = "1.7.1"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-espresso = "3.7.0"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-navigation = "2.8.8"
|
||||
androidx-testExt = "1.3.0"
|
||||
composeMultiplatform = "1.10.3"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.20"
|
||||
kotlinx-serialization = "1.8.0"
|
||||
material3 = "1.10.0-alpha05"
|
||||
ktor = "3.4.3"
|
||||
|
||||
[libraries]
|
||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||
@@ -23,6 +26,8 @@ androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "an
|
||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||
@@ -32,6 +37,12 @@ compose-material3 = { module = "org.jetbrains.compose.material3:material3", vers
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" }
|
||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeMultiplatform" }
|
||||
compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" }
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", 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-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
alias(libs.plugins.androidLibrary)
|
||||
kotlin("plugin.serialization") version libs.versions.kotlin.get()
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -24,7 +25,21 @@ kotlin {
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
// put your Multiplatform dependencies here
|
||||
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||
implementation("com.squareup.okio:okio:3.16.2")
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.websockets)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlin.test)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package su.reya.coop
|
||||
|
||||
import android.os.Build
|
||||
|
||||
class AndroidPlatform : Platform {
|
||||
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||
75
shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt
Normal file
75
shared/src/commonMain/kotlin/su/reya/coop/CoopWebSocket.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package su.reya.coop
|
||||
|
||||
class Greeting {
|
||||
private val platform = getPlatform()
|
||||
|
||||
fun greet(): String {
|
||||
return "Hello, ${platform.name}!"
|
||||
}
|
||||
}
|
||||
870
shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
Normal file
870
shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
Normal file
@@ -0,0 +1,870 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
533
shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
Normal file
533
shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
Normal file
@@ -0,0 +1,533 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package su.reya.coop
|
||||
|
||||
interface Platform {
|
||||
val name: String
|
||||
}
|
||||
|
||||
expect fun getPlatform(): Platform
|
||||
171
shared/src/commonMain/kotlin/su/reya/coop/Room.kt
Normal file
171
shared/src/commonMain/kotlin/su/reya/coop/Room.kt
Normal file
@@ -0,0 +1,171 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.minus
|
||||
import kotlinx.datetime.number
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.TagKind
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
enum class RoomKind {
|
||||
Ongoing,
|
||||
Request;
|
||||
|
||||
companion object {
|
||||
fun default(): RoomKind = Request
|
||||
}
|
||||
}
|
||||
|
||||
data class Room(
|
||||
val id: Long,
|
||||
val createdAt: Timestamp,
|
||||
val subject: String?,
|
||||
val members: Set<PublicKey>,
|
||||
val kind: RoomKind = RoomKind.default(),
|
||||
val lastMessage: String? = null
|
||||
) : Comparable<Room> {
|
||||
override fun compareTo(other: Room): Int {
|
||||
return this.createdAt.asSecs().compareTo(other.createdAt.asSecs())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun new(rumor: UnsignedEvent, userPubkey: PublicKey): Room {
|
||||
val id = rumor.roomId()
|
||||
val createdAt = rumor.createdAt()
|
||||
val subject = rumor.tags().find(TagKind.Subject)?.content()
|
||||
|
||||
// Collect the author's public key and all public keys from tags
|
||||
// Also remove the user's public key from the list, current user is always a member
|
||||
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
||||
pubkeys.add(rumor.author())
|
||||
pubkeys.addAll(rumor.tags().publicKeys())
|
||||
pubkeys.remove(userPubkey)
|
||||
|
||||
// Create a new Room instance
|
||||
return Room(
|
||||
id = id,
|
||||
createdAt = createdAt,
|
||||
subject = subject,
|
||||
members = pubkeys as Set<PublicKey>,
|
||||
lastMessage = rumor.content()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setKind(kind: RoomKind): Room {
|
||||
return this.copy(kind = kind)
|
||||
}
|
||||
|
||||
fun setCreatedAt(createdAt: Timestamp): Room {
|
||||
return this.copy(createdAt = createdAt)
|
||||
}
|
||||
|
||||
fun setSubject(subject: String?): Room {
|
||||
return this.copy(subject = subject)
|
||||
}
|
||||
|
||||
fun setLastMessage(message: String?): Room {
|
||||
return this.copy(lastMessage = message)
|
||||
}
|
||||
|
||||
fun isGroup(): Boolean {
|
||||
return members.size > 1
|
||||
}
|
||||
}
|
||||
|
||||
fun UnsignedEvent.roomId(): Long {
|
||||
// Collect the author's public key and all public keys from tags
|
||||
val pubkeys: MutableList<PublicKey> = mutableListOf()
|
||||
pubkeys.add(this.author())
|
||||
pubkeys.addAll(this.tags().publicKeys())
|
||||
|
||||
// Sort and hash the list of public keys
|
||||
val sortedUniqueKeys = pubkeys
|
||||
.distinctBy { it.toBech32() }
|
||||
.sortedBy { it.toBech32() }
|
||||
|
||||
return sortedUniqueKeys.hashCode().toLong()
|
||||
}
|
||||
|
||||
fun Timestamp.ago(): String {
|
||||
val SECONDS_IN_MINUTE = 60L
|
||||
val MINUTES_IN_HOUR = 60L
|
||||
val HOURS_IN_DAY = 24L
|
||||
val DAYS_IN_MONTH = 30L
|
||||
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val now = Clock.System.now()
|
||||
val duration = now - inputInstant
|
||||
|
||||
return when {
|
||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
||||
else -> {
|
||||
val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault())
|
||||
val month =
|
||||
localDateTime.month.name.take(3).lowercase().replaceFirstChar { it.uppercase() }
|
||||
val day = localDateTime.dayOfMonth.toString().padStart(2, '0')
|
||||
"$month $day"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Timestamp.formatAsGroupHeader(): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val inputDate = inputInstant.toLocalDateTime(timeZone).date
|
||||
|
||||
val now = Clock.System.now()
|
||||
val today = now.toLocalDateTime(timeZone).date
|
||||
val yesterday = today.minus(1, DateTimeUnit.DAY)
|
||||
|
||||
return when (inputDate) {
|
||||
today -> "Today"
|
||||
yesterday -> "Yesterday"
|
||||
else -> {
|
||||
val day = inputDate.day.toString().padStart(2, '0')
|
||||
val month = inputDate.month.number.toString().padStart(2, '0')
|
||||
val year = inputDate.year.toString().takeLast(2)
|
||||
"$day/$month/$year"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Timestamp.humanReadable(): String {
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong())
|
||||
val inputDateTime = inputInstant.toLocalDateTime(timeZone)
|
||||
val inputDate = inputDateTime.date
|
||||
|
||||
val now = Clock.System.now()
|
||||
val today = now.toLocalDateTime(timeZone).date
|
||||
val yesterday = today.minus(1, DateTimeUnit.DAY)
|
||||
|
||||
val hour = inputDateTime.hour
|
||||
val minute = inputDateTime.minute.toString().padStart(2, '0')
|
||||
val amPm = if (hour < 12) "AM" else "PM"
|
||||
val hour12 = when {
|
||||
hour == 0 -> 12
|
||||
hour > 12 -> hour - 12
|
||||
else -> hour
|
||||
}
|
||||
val timeFormat = "$hour12:$minute $amPm"
|
||||
|
||||
return when (inputDate) {
|
||||
today -> "Today at $timeFormat"
|
||||
yesterday -> "Yesterday at $timeFormat"
|
||||
else -> {
|
||||
val day = inputDateTime.day.toString().padStart(2, '0')
|
||||
val month = inputDateTime.month.number.toString().padStart(2, '0')
|
||||
val year = inputDateTime.year.toString().takeLast(2)
|
||||
"$day/$month/$year, $timeFormat"
|
||||
}
|
||||
}
|
||||
}
|
||||
55
shared/src/commonMain/kotlin/su/reya/coop/Signer.kt
Normal file
55
shared/src/commonMain/kotlin/su/reya/coop/Signer.kt
Normal file
@@ -0,0 +1,55 @@
|
||||
package su.reya.coop
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.Event
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
|
||||
class UniversalSigner(initialSigner: AsyncNostrSigner) : AsyncNostrSigner {
|
||||
private val mutex = Mutex()
|
||||
private var signer: AsyncNostrSigner = initialSigner
|
||||
|
||||
var currentUser: PublicKey? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Get the current signer.
|
||||
*/
|
||||
suspend fun get(): AsyncNostrSigner = mutex.withLock {
|
||||
signer
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a new signer.
|
||||
*/
|
||||
suspend fun switch(newSigner: AsyncNostrSigner) = mutex.withLock {
|
||||
signer = newSigner
|
||||
currentUser = newSigner.getPublicKeyAsync()
|
||||
}
|
||||
|
||||
override suspend fun getPublicKeyAsync(): PublicKey? {
|
||||
return get().getPublicKeyAsync()
|
||||
}
|
||||
|
||||
override suspend fun signEventAsync(unsignedEvent: UnsignedEvent): Event? {
|
||||
return get().signEventAsync(unsignedEvent)
|
||||
}
|
||||
|
||||
override suspend fun nip04EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||
return get().nip04EncryptAsync(publicKey, content)
|
||||
}
|
||||
|
||||
override suspend fun nip04DecryptAsync(publicKey: PublicKey, encryptedContent: String): String {
|
||||
return get().nip04DecryptAsync(publicKey, encryptedContent)
|
||||
}
|
||||
|
||||
override suspend fun nip44EncryptAsync(publicKey: PublicKey, content: String): String {
|
||||
return get().nip44EncryptAsync(publicKey, content)
|
||||
}
|
||||
|
||||
override suspend fun nip44DecryptAsync(publicKey: PublicKey, payload: String): String {
|
||||
return get().nip44DecryptAsync(publicKey, payload)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package su.reya.coop.blossom
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.put
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.http.HeaderValue
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.utils.io.core.toByteArray
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import rust.nostr.sdk.AsyncNostrSigner
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Timestamp
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.time.Duration
|
||||
|
||||
class BlossomClient(
|
||||
val url: String,
|
||||
val client: HttpClient,
|
||||
) {
|
||||
suspend fun upload(
|
||||
file: ByteArray,
|
||||
contentType: String? = null,
|
||||
signer: AsyncNostrSigner? = null
|
||||
): BlobDescriptor? {
|
||||
val url = "$url/upload"
|
||||
val hash = file.toByteString().sha256().hex()
|
||||
val fileHashes = listOf(hash)
|
||||
|
||||
val res = client.put(url) {
|
||||
// Set body
|
||||
setBody(file)
|
||||
|
||||
// Set the content type if provided
|
||||
contentType?.let {
|
||||
header(HttpHeaders.ContentType, it)
|
||||
}
|
||||
|
||||
signer?.let {
|
||||
val defaultAuth = defaultAuth(
|
||||
action = BlossomAuthorizationVerb.Upload,
|
||||
defaultContent = "Blossom upload authorization",
|
||||
defaultScope = BlossomAuthorizationScope.BlobSha256Hashes(fileHashes)
|
||||
)
|
||||
val authHeader = buildAuthHeader(it, defaultAuth)
|
||||
header(HttpHeaders.Authorization, authHeader.value)
|
||||
}
|
||||
}
|
||||
|
||||
return when (res.status) {
|
||||
HttpStatusCode.OK -> res.body<BlobDescriptor>()
|
||||
else -> {
|
||||
throw Exception("Failed to upload file: ${res.status}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun defaultAuth(
|
||||
action: BlossomAuthorizationVerb,
|
||||
defaultContent: String,
|
||||
defaultScope: BlossomAuthorizationScope
|
||||
): BlossomAuthorization {
|
||||
val expiration = Timestamp.now().addDuration(Duration.parse("300s"))
|
||||
return BlossomAuthorization(
|
||||
content = defaultContent,
|
||||
expiration = expiration,
|
||||
action = action,
|
||||
scope = defaultScope
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun buildAuthHeader(
|
||||
signer: AsyncNostrSigner,
|
||||
authz: BlossomAuthorization
|
||||
): HeaderValue {
|
||||
val authEvent = EventBuilder.blossomAuth(authz).signAsync(signer)
|
||||
val encodedAuth = Base64.encode(authEvent.asJson().toByteArray())
|
||||
val value = "Nostr $encodedAuth"
|
||||
return HeaderValue(value)
|
||||
}
|
||||
}
|
||||
92
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt
Normal file
92
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud01.kt
Normal file
@@ -0,0 +1,92 @@
|
||||
package su.reya.coop.blossom
|
||||
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.Kind
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.Timestamp
|
||||
|
||||
/**
|
||||
* Represents the authorization data for accessing a Blossom server.
|
||||
*/
|
||||
data class BlossomAuthorization(
|
||||
/**
|
||||
* A human readable string explaining to the user what the events intended use is
|
||||
*/
|
||||
val content: String,
|
||||
/**
|
||||
* A UNIX timestamp (in seconds) indicating when the authorization should be expired
|
||||
*/
|
||||
val expiration: Timestamp,
|
||||
/**
|
||||
* The type of action authorized by the user
|
||||
*/
|
||||
val action: BlossomAuthorizationVerb,
|
||||
/**
|
||||
* The scope of the authorization
|
||||
*/
|
||||
val scope: BlossomAuthorizationScope,
|
||||
)
|
||||
|
||||
/**
|
||||
* The scope of a Blossom authorization event
|
||||
*/
|
||||
sealed class BlossomAuthorizationScope {
|
||||
/**
|
||||
* Authorizes access to blobs with the given SHA256 hashes.
|
||||
*/
|
||||
data class BlobSha256Hashes(val hashes: List<String>) : BlossomAuthorizationScope()
|
||||
|
||||
/**
|
||||
* Authorizes access to the given server URL.
|
||||
*/
|
||||
data class ServerUrl(val url: String) : BlossomAuthorizationScope()
|
||||
|
||||
fun toTags(): List<Tag> {
|
||||
return when (this) {
|
||||
is BlobSha256Hashes -> hashes.map { hash ->
|
||||
// "x" tag for blob hash
|
||||
Tag.parse(listOf("x", hash))
|
||||
}
|
||||
|
||||
is ServerUrl -> listOf(
|
||||
// "server" tag for server URL
|
||||
Tag.parse(listOf("server", url))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the possible actions that can be authorized by a Blossom authorization event.
|
||||
*/
|
||||
enum class BlossomAuthorizationVerb(val value: String) {
|
||||
Get("get"),
|
||||
Upload("upload"),
|
||||
List("list"),
|
||||
Delete("delete");
|
||||
|
||||
override fun toString(): String = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension functions for [BlossomAuthorization] and [EventBuilder].
|
||||
*/
|
||||
fun BlossomAuthorization.toTags(): List<Tag> {
|
||||
val tags = mutableListOf<Tag>()
|
||||
tags.addAll(scope.toTags())
|
||||
tags.add(Tag.expiration(expiration))
|
||||
// Add the 't' tag to say what this auth is for
|
||||
tags.add(Tag.hashtag(action.toString()))
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Blossom authorization event (Kind 24242)
|
||||
*
|
||||
* https://github.com/hzrd149/blossom/blob/master/buds/01.md
|
||||
*/
|
||||
fun EventBuilder.Companion.blossomAuth(authorization: BlossomAuthorization): EventBuilder {
|
||||
// Kind 24242 is used for Blossom Auth
|
||||
val kind = Kind(24242u)
|
||||
return EventBuilder(kind, authorization.content).tags(authorization.toTags())
|
||||
}
|
||||
27
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt
Normal file
27
shared/src/commonMain/kotlin/su/reya/coop/blossom/Bud02.kt
Normal file
@@ -0,0 +1,27 @@
|
||||
package su.reya.coop.blossom
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class BlobDescriptor(
|
||||
/**
|
||||
* The URL at which the blob/file can be accessed
|
||||
*/
|
||||
val url: String,
|
||||
/**
|
||||
* The SHA256 hash of the contents in the blob
|
||||
*/
|
||||
val sha256: String,
|
||||
/**
|
||||
* The size of the blob/file, in bytes
|
||||
*/
|
||||
val size: Long,
|
||||
/**
|
||||
* Mime type of the blob/file
|
||||
*/
|
||||
val mimeType: String? = null,
|
||||
/**
|
||||
* The date at which the blob was uploaded, as a UNIX timestamp (in seconds)
|
||||
*/
|
||||
val uploaded: ULong
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package su.reya.coop.storage
|
||||
|
||||
interface SecretStorage {
|
||||
suspend fun get(key: String): String?
|
||||
suspend fun set(key: String, value: String)
|
||||
suspend fun clear(key: String)
|
||||
suspend fun has(key: String): Boolean
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package su.reya.coop
|
||||
|
||||
import platform.UIKit.UIDevice
|
||||
|
||||
class IOSPlatform: Platform {
|
||||
override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
|
||||
}
|
||||
|
||||
actual fun getPlatform(): Platform = IOSPlatform()
|
||||
Reference in New Issue
Block a user