Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ab489d44 | |||
| b88674d6e2 | |||
| e9eb071208 | |||
| a2a4433a9d | |||
| 1c08525dfc | |||
| 83af44002c | |||
| 44acbfa6b7 |
@@ -19,15 +19,15 @@ kotlin {
|
|||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation("androidx.navigation:navigation-compose:2.8.8")
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
implementation("androidx.datastore:datastore-preferences-core:1.2.1")
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07")
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
|
implementation(libs.androidx.core.splashscreen)
|
||||||
|
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
||||||
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
implementation("io.coil-kt.coil3:coil-compose:3.4.0")
|
||||||
implementation("io.coil-kt.coil3:coil-network-okhttp: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("io.github.kalinjul.easyqrscan:scanner:0.7.0")
|
||||||
implementation("androidx.lifecycle:lifecycle-process:2.8.0")
|
|
||||||
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
implementation("io.github.alexzhirkevich:qrose:1.1.2")
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
@@ -39,6 +39,8 @@ kotlin {
|
|||||||
implementation(libs.compose.uiToolingPreview)
|
implementation(libs.compose.uiToolingPreview)
|
||||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.androidx.datastore)
|
||||||
implementation(projects.shared)
|
implementation(projects.shared)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
@@ -67,7 +69,7 @@ android {
|
|||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.1"
|
versionName = "0.1.3"
|
||||||
}
|
}
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
|
|||||||
@@ -18,14 +18,29 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.App.Starting">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="chat"
|
||||||
|
android:scheme="coop" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".NostrForegroundService"
|
android:name=".NostrForegroundService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|||||||
@@ -1,18 +1,3 @@
|
|||||||
<!--
|
|
||||||
~ Copyright (C) 2026 The Android Open Source Project
|
|
||||||
~
|
|
||||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
~ you may not use this file except in compliance with the License.
|
|
||||||
~ You may obtain a copy of the License at
|
|
||||||
~
|
|
||||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
~
|
|
||||||
~ Unless required by applicable law or agreed to in writing, software
|
|
||||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
~ See the License for the specific language governing permissions and
|
|
||||||
~ limitations under the License.
|
|
||||||
-->
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="750dp"
|
android:width="750dp"
|
||||||
android:height="750dp"
|
android:height="750dp"
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,360Q80,327 103.5,303.5Q127,280 160,280L360,280L360,160Q360,127 383.5,103.5Q407,80 440,80L520,80Q553,80 576.5,103.5Q600,127 600,160L600,280L800,280Q833,280 856.5,303.5Q880,327 880,360L880,800Q880,833 856.5,856.5Q833,880 800,880L160,880ZM160,800L800,800Q800,800 800,800Q800,800 800,800L800,360Q800,360 800,360Q800,360 800,360L600,360L600,360Q600,393 576.5,416.5Q553,440 520,440L440,440Q407,440 383.5,416.5Q360,393 360,360L360,360L160,360Q160,360 160,360Q160,360 160,360L160,800Q160,800 160,800Q160,800 160,800ZM240,720L480,720L480,702Q480,685 470.5,670.5Q461,656 444,648Q424,639 403.5,634.5Q383,630 360,630Q337,630 316.5,634.5Q296,639 276,648Q259,656 249.5,670.5Q240,685 240,702L240,720ZM560,660L720,660L720,600L560,600L560,660ZM402.5,582.5Q420,565 420,540Q420,515 402.5,497.5Q385,480 360,480Q335,480 317.5,497.5Q300,515 300,540Q300,565 317.5,582.5Q335,600 360,600Q385,600 402.5,582.5ZM560,540L720,540L720,480L560,480L560,540ZM440,360L520,360L520,160L440,160L440,360ZM480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580L480,580L480,580Q480,580 480,580Q480,580 480,580L480,580Q480,580 480,580Q480,580 480,580Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M360,840L360,760L240,760L240,680L320,680L320,280L240,280L240,200L360,200L360,120L440,120L440,200L520,200L520,120L600,120L600,205L600,205Q652,219 686,261.5Q720,304 720,360Q720,389 710,415.5Q700,442 682,463Q717,484 738.5,520Q760,556 760,600Q760,666 713,713Q666,760 600,760L600,760L600,840L520,840L520,760L440,760L440,840L360,840ZM400,440L560,440Q593,440 616.5,416.5Q640,393 640,360Q640,327 616.5,303.5Q593,280 560,280L400,280L400,440ZM400,680L600,680Q633,680 656.5,656.5Q680,633 680,600Q680,567 656.5,543.5Q633,520 600,520L400,520L400,680Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M80,880L80,160Q80,127 103.5,103.5Q127,80 160,80L800,80Q833,80 856.5,103.5Q880,127 880,160L880,640Q880,673 856.5,696.5Q833,720 800,720L240,720L80,880ZM206,640L800,640Q800,640 800,640Q800,640 800,640L800,160Q800,160 800,160Q800,160 800,160L160,160Q160,160 160,160Q160,160 160,160L160,685L206,640ZM160,640L160,640L160,160Q160,160 160,160Q160,160 160,160L160,160Q160,160 160,160Q160,160 160,160L160,640Q160,640 160,640Q160,640 160,640Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,473 799.5,465.5Q799,458 799,453Q794,482 772,501Q750,520 720,520L640,520Q607,520 583.5,496.5Q560,473 560,440L560,400L400,400L400,320Q400,287 423.5,263.5Q447,240 480,240L520,240L520,240Q520,217 532.5,199.5Q545,182 563,171Q543,166 522.5,163Q502,160 480,160Q346,160 253,253Q160,346 160,480Q160,480 160,480Q160,480 160,480L360,480Q426,480 473,527Q520,574 520,640L520,680L400,680L400,790Q420,795 439.5,797.5Q459,800 480,800Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240Q697,240 708.5,228.5ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z" />
|
||||||
|
</vector>
|
||||||
Binary file not shown.
@@ -1,5 +1,9 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -15,8 +19,10 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
@@ -27,8 +33,10 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -37,14 +45,15 @@ import androidx.compose.ui.text.font.FontStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.core.util.Consumer
|
||||||
import androidx.navigation.NavController
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation3.runtime.entryProvider
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation3.runtime.rememberNavBackStack
|
||||||
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import su.reya.coop.coop.storage.SecretStore
|
|
||||||
import su.reya.coop.screens.ChatScreen
|
import su.reya.coop.screens.ChatScreen
|
||||||
import su.reya.coop.screens.HomeScreen
|
import su.reya.coop.screens.HomeScreen
|
||||||
import su.reya.coop.screens.ImportScreen
|
import su.reya.coop.screens.ImportScreen
|
||||||
@@ -52,6 +61,7 @@ import su.reya.coop.screens.MyQrScreen
|
|||||||
import su.reya.coop.screens.NewChatScreen
|
import su.reya.coop.screens.NewChatScreen
|
||||||
import su.reya.coop.screens.NewIdentityScreen
|
import su.reya.coop.screens.NewIdentityScreen
|
||||||
import su.reya.coop.screens.OnboardingScreen
|
import su.reya.coop.screens.OnboardingScreen
|
||||||
|
import su.reya.coop.screens.ProfileScreen
|
||||||
import su.reya.coop.screens.RelayScreen
|
import su.reya.coop.screens.RelayScreen
|
||||||
import su.reya.coop.screens.ScanScreen
|
import su.reya.coop.screens.ScanScreen
|
||||||
|
|
||||||
@@ -63,33 +73,50 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
|||||||
error("No SnackbarHostState provided")
|
error("No SnackbarHostState provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
val LocalNavigator = staticCompositionLocalOf<Navigator> {
|
||||||
error("No NavController provided")
|
error("No Navigator provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalScanResult = staticCompositionLocalOf<QrScanResult> {
|
||||||
|
error("No QrScanResult provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App(viewModel: NostrViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val navController = rememberNavController()
|
val activity = context as? ComponentActivity
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val darkMode = isSystemInDarkTheme()
|
val sheetState = rememberModalBottomSheetState()
|
||||||
|
val backStack = rememberNavBackStack(Screen.Home)
|
||||||
|
val navigator = remember(backStack) { Navigator(backStack) }
|
||||||
|
val qrScanResult = remember { QrScanResult() }
|
||||||
|
|
||||||
|
val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
|
||||||
|
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
||||||
|
|
||||||
// Snackbar
|
// Snackbar
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
// Initialize Nostr View Model and Secret Store
|
// Check if dark theme enabled
|
||||||
val secretStore = remember { SecretStore(context) }
|
val darkMode = isSystemInDarkTheme()
|
||||||
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
|
|
||||||
|
|
||||||
// Enabled the dynamic color scheme
|
// Enabled the dynamic color scheme
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
|
// Enable the dynamic color scheme for Android 12+
|
||||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
if (isSystemInDarkTheme()) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When dark mode is enabled, use the dark color scheme
|
||||||
|
darkMode -> darkColorScheme()
|
||||||
|
// Fallback to the light color scheme
|
||||||
|
else -> expressiveLightColorScheme()
|
||||||
}
|
}
|
||||||
|
|
||||||
darkMode -> darkColorScheme()
|
BackHandler(enabled = backStack.size > 1) {
|
||||||
else -> expressiveLightColorScheme()
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -98,29 +125,110 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activity) {
|
||||||
|
activity?.let {
|
||||||
|
fun handleIntent(intent: Intent) {
|
||||||
|
val screen = Screen.fromIntent(intent)
|
||||||
|
// Prevent pushing the same screen
|
||||||
|
if (screen != null && backStack.lastOrNull() != screen) {
|
||||||
|
navigator.navigate(screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the intent that started the Activity
|
||||||
|
handleIntent(it.intent)
|
||||||
|
|
||||||
|
// Handle new intents while the Activity is running
|
||||||
|
val listener = Consumer<Intent> { intent -> handleIntent(intent) }
|
||||||
|
it.addOnNewIntentListener(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(backStack.size) {
|
||||||
|
if (backStack.isEmpty()) {
|
||||||
|
(context as? Activity)?.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(signerRequired) {
|
||||||
|
if (signerRequired == true && backStack.last() != Screen.Onboarding) {
|
||||||
|
backStack.clear()
|
||||||
|
backStack.add(Screen.Onboarding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MaterialExpressiveTheme(
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography(),
|
||||||
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNostrViewModel provides viewModel,
|
LocalNostrViewModel provides viewModel,
|
||||||
LocalSnackbarHostState provides snackbarHostState,
|
LocalSnackbarHostState provides snackbarHostState,
|
||||||
LocalNavController provides navController,
|
LocalNavigator provides navigator,
|
||||||
|
LocalScanResult provides qrScanResult,
|
||||||
) {
|
) {
|
||||||
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
NavDisplay(
|
||||||
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
|
backStack = backStack,
|
||||||
val sheetState = rememberModalBottomSheetState()
|
onBack = {
|
||||||
|
if (backStack.size > 1) {
|
||||||
LaunchedEffect(emptySecret) {
|
backStack.removeLastOrNull()
|
||||||
// Navigate to the home screen if the secret is already set
|
} else {
|
||||||
if (emptySecret == false) {
|
(context as? Activity)?.finish()
|
||||||
navController.navigate(Screen.Home) {
|
}
|
||||||
popUpTo(Screen.Onboarding) { inclusive = true }
|
},
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Home> {
|
||||||
|
HomeScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Onboarding> {
|
||||||
|
OnboardingScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Import> {
|
||||||
|
ImportScreen(
|
||||||
|
onSave = { secret ->
|
||||||
|
viewModel.importIdentity(secret)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<Screen.NewIdentity> {
|
||||||
|
NewIdentityScreen(
|
||||||
|
onSave = { name, bio, uri ->
|
||||||
|
val contentType =
|
||||||
|
uri?.let { context.contentResolver.getType(it) }
|
||||||
|
val picture = uri?.let {
|
||||||
|
context.contentResolver.openInputStream(it)?.use { input ->
|
||||||
|
input.readBytes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModel.createIdentity(name, bio, picture, contentType)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
// Show loading screen while initializing
|
}
|
||||||
if (emptySecret == null) return@CompositionLocalProvider
|
entry<Screen.Chat> { key ->
|
||||||
|
ChatScreen(id = key.id)
|
||||||
|
}
|
||||||
|
entry<Screen.NewChat> {
|
||||||
|
NewChatScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Profile> { key ->
|
||||||
|
ProfileScreen(pubkey = key.pubkey)
|
||||||
|
}
|
||||||
|
entry<Screen.Scan> {
|
||||||
|
ScanScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.MyQr> {
|
||||||
|
MyQrScreen()
|
||||||
|
}
|
||||||
|
entry<Screen.Relay> {
|
||||||
|
RelayScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Show the relay setup dialog if the msg relay list is empty
|
// Show the relay setup dialog if the msg relay list is empty
|
||||||
if (isRelayListEmpty) {
|
if (isRelayListEmpty) {
|
||||||
@@ -176,79 +284,26 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding
|
|
||||||
) {
|
|
||||||
composable<Screen.Onboarding> { backStackEntry ->
|
|
||||||
OnboardingScreen(
|
|
||||||
onOpenImport = { navController.navigate(Screen.Import) },
|
|
||||||
onOpenNew = { navController.navigate(Screen.NewIdentity) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Import> { backStackEntry ->
|
|
||||||
val isCreating by viewModel.isCreating.collectAsState()
|
|
||||||
|
|
||||||
ImportScreen(
|
|
||||||
isLoading = isCreating,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSave = { secret ->
|
|
||||||
viewModel.importIdentity(secret)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.NewIdentity> { backStackEntry ->
|
|
||||||
val isCreating by viewModel.isCreating.collectAsState()
|
|
||||||
|
|
||||||
NewIdentityScreen(
|
|
||||||
isLoading = isCreating,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSave = { name, bio, uri ->
|
|
||||||
val contentType = uri?.let { context.contentResolver.getType(it) }
|
|
||||||
val picture = uri?.let {
|
|
||||||
context.contentResolver.openInputStream(it)?.use { input ->
|
|
||||||
input.readBytes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.createIdentity(name, bio, picture, contentType)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Home> { backStackEntry ->
|
|
||||||
HomeScreen(
|
|
||||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
|
||||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Chat> { backStackEntry ->
|
|
||||||
val chat: Screen.Chat = backStackEntry.toRoute()
|
|
||||||
ChatScreen(
|
|
||||||
id = chat.id,
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.NewChat> { backStackEntry ->
|
|
||||||
NewChatScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Scan> { backStackEntry ->
|
|
||||||
ScanScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.MyQr> { backStackEntry ->
|
|
||||||
MyQrScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable<Screen.Relay> { backStackEntry ->
|
|
||||||
RelayScreen(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Navigator(private val backStack: NavBackStack<NavKey>) {
|
||||||
|
fun navigate(route: NavKey) {
|
||||||
|
backStack.add(route)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goBack() {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeAt(backStack.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class QrScanResult {
|
||||||
|
var content by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
content = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,22 +6,48 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import su.reya.coop.coop.storage.SecretStore
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val viewModel: NostrViewModel by viewModels {
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
val secretStore = SecretStore(this@MainActivity)
|
||||||
|
return NostrViewModel(NostrManager.instance, secretStore) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
val splashScreen = installSplashScreen()
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val intent = Intent(this, NostrForegroundService::class.java)
|
val serviceIntent = Intent(this, NostrForegroundService::class.java)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
startForegroundService(intent)
|
startForegroundService(serviceIntent)
|
||||||
} else {
|
} else {
|
||||||
startService(intent)
|
startService(serviceIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the splash screen visible until the signer check is complete
|
||||||
|
splashScreen.setKeepOnScreenCondition {
|
||||||
|
viewModel.signerRequired.value == null
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App(viewModel = viewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
package su.reya.coop
|
package su.reya.coop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
sealed interface Screen {
|
sealed interface Screen : NavKey {
|
||||||
|
companion object {
|
||||||
|
fun fromIntent(intent: Intent): Screen? {
|
||||||
|
val data = intent.data ?: return null
|
||||||
|
if (data.scheme != "coop") return null
|
||||||
|
|
||||||
|
return when (data.host) {
|
||||||
|
// Matches coop://chat/{id}
|
||||||
|
"chat" -> data.pathSegments.firstOrNull()?.toLongOrNull()?.let { Chat(it) }
|
||||||
|
// Matches coop://profile/{pubkey}
|
||||||
|
"profile" -> data.pathSegments.firstOrNull()?.let { Profile(it) }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Home : Screen
|
data object Home : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Chat(val id: Long) : Screen
|
data class Chat(val id: Long) : Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Profile(val pubkey: String) : Screen
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object NewChat : Screen
|
data object NewChat : Screen
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package su.reya.coop
|
|||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -28,10 +30,12 @@ class NostrForegroundService : Service() {
|
|||||||
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
val notification = createNotification("Connecting to Nostr...")
|
}
|
||||||
|
|
||||||
|
val notification = createNotification()
|
||||||
startForeground(1, notification)
|
startForeground(1, notification)
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
@@ -43,11 +47,25 @@ class NostrForegroundService : Service() {
|
|||||||
// Connect to bootstrap relays
|
// Connect to bootstrap relays
|
||||||
nostr.connectBootstrapRelays()
|
nostr.connectBootstrapRelays()
|
||||||
// Handle notifications
|
// Handle notifications
|
||||||
nostr.handleLiteNotifications { event ->
|
nostr.handleNotifications(
|
||||||
|
onMetadataUpdate = { pubkey, metadata ->
|
||||||
|
serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
|
||||||
|
},
|
||||||
|
onContactListUpdate = { contacts ->
|
||||||
|
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
|
||||||
|
},
|
||||||
|
onSubscriptionClose = {
|
||||||
|
serviceScope.launch { nostr.emitSubscriptionClosed() }
|
||||||
|
},
|
||||||
|
onNewMessage = { event ->
|
||||||
|
serviceScope.launch {
|
||||||
if (!isUserInApp()) {
|
if (!isUserInApp()) {
|
||||||
showNewMessageNotification(event.content())
|
showNewMessageNotification(event.roomId(), event.content())
|
||||||
|
}
|
||||||
|
nostr.emitNewEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("Failed to start Nostr in background: ${e.message}")
|
println("Failed to start Nostr in background: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -58,30 +76,68 @@ class NostrForegroundService : Service() {
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun createNotificationChannel() {
|
private fun createNotificationChannel() {
|
||||||
val channel = NotificationChannel(
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
"nostr_service",
|
"nostr_service",
|
||||||
"Nostr Background Service",
|
"Nostr Background Status",
|
||||||
|
NotificationManager.IMPORTANCE_MIN
|
||||||
|
).apply {
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
manager?.createNotificationChannel(serviceChannel)
|
||||||
|
|
||||||
|
val messageChannel = NotificationChannel(
|
||||||
|
"nostr_messages",
|
||||||
|
"New Messages",
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
)
|
)
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
manager?.createNotificationChannel(messageChannel)
|
||||||
manager?.createNotificationChannel(channel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotification(content: String): Notification {
|
private fun createNotification(content: String? = null): Notification {
|
||||||
return NotificationCompat.Builder(this, "nostr_service")
|
val builder = NotificationCompat.Builder(this, "nostr_service")
|
||||||
.setContentTitle("Coop")
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentText(content)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_menu_send)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.build()
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setCategory(Notification.CATEGORY_SERVICE)
|
||||||
|
|
||||||
|
if (content != null) {
|
||||||
|
builder.setContentTitle("Coop")
|
||||||
|
builder.setContentText(content)
|
||||||
|
} else {
|
||||||
|
builder.setContentTitle("Coop is active")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showNewMessageNotification(message: String) {
|
return builder.build()
|
||||||
val notification = NotificationCompat.Builder(this, "nostr_service")
|
}
|
||||||
.setContentTitle("New Message")
|
|
||||||
|
private fun showNewMessageNotification(roomId: Long, message: String) {
|
||||||
|
val deepLinkUri = "coop://chat/$roomId".toUri()
|
||||||
|
|
||||||
|
val intent = Intent(
|
||||||
|
Intent.ACTION_VIEW,
|
||||||
|
deepLinkUri,
|
||||||
|
this,
|
||||||
|
MainActivity::class.java
|
||||||
|
)
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
roomId.toInt(),
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, "nostr_messages")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle("You received a new message")
|
||||||
.setContentText(message)
|
.setContentText(message)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setCategory(Notification.CATEGORY_MESSAGE)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
manager?.notify(System.currentTimeMillis().toInt(), notification)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -37,6 +39,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -51,8 +54,10 @@ import coop.composeapp.generated.resources.ic_send
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.UnsignedEvent
|
import rust.nostr.sdk.UnsignedEvent
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.formatAsGroupHeader
|
import su.reya.coop.formatAsGroupHeader
|
||||||
import su.reya.coop.roomId
|
import su.reya.coop.roomId
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -61,34 +66,40 @@ import su.reya.coop.shared.pictureFlow
|
|||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(id: Long) {
|
||||||
id: Long,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val room = viewModel.getChatRoom(id)
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val chatRooms by viewModel.chatRooms.collectAsState()
|
||||||
|
val room = remember(chatRooms, id) { chatRooms.firstOrNull { it.id == id } }
|
||||||
|
|
||||||
|
if (room == null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
LoadingIndicator()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
|
||||||
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
|
||||||
|
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
var loading by remember { mutableStateOf(true) }
|
var loading by remember { mutableStateOf(true) }
|
||||||
|
var newOtherMessages by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
val messages = remember { mutableStateListOf<UnsignedEvent>() }
|
||||||
val groupedMessages = remember(messages.toList()) {
|
val groupedMessages = remember(messages.toList()) {
|
||||||
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
messages.groupBy { it.createdAt().formatAsGroupHeader() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLoading(value: Boolean) {
|
|
||||||
loading = value
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(id) {
|
LaunchedEffect(id) {
|
||||||
// Start loading spinner
|
// Start loading spinner
|
||||||
setLoading(true)
|
loading = true
|
||||||
|
|
||||||
// Get messages
|
// Get messages
|
||||||
val initialMessages = viewModel.getChatRoomMessages(id)
|
val initialMessages = viewModel.getChatRoomMessages(id)
|
||||||
@@ -108,7 +119,7 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Stop loading spinner
|
// Stop loading spinner
|
||||||
setLoading(false)
|
loading = false
|
||||||
|
|
||||||
// Handle new messages
|
// Handle new messages
|
||||||
viewModel.newEvents.collect { event ->
|
viewModel.newEvents.collect { event ->
|
||||||
@@ -116,6 +127,9 @@ fun ChatScreen(
|
|||||||
if (event.id() !in messages.map { it.id() }) {
|
if (event.id() !in messages.map { it.id() }) {
|
||||||
messages.add(0, event)
|
messages.add(0, event)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// If the event is not in the current room, it's a new message from another user
|
||||||
|
newOtherMessages++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,7 +146,14 @@ fun ChatScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
room.members.firstOrNull()?.let { pubkey ->
|
||||||
|
navigator.navigate(Screen.Profile(pubkey.toBech32()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
LoadingIndicator(
|
LoadingIndicator(
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
@@ -152,12 +173,22 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (newOtherMessages > 0) {
|
||||||
|
Badge {
|
||||||
|
Text(newOtherMessages.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package su.reya.coop.screens
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -58,6 +59,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -68,8 +70,9 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Room
|
import su.reya.coop.Room
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
@@ -81,13 +84,11 @@ import su.reya.coop.short
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen() {
|
||||||
onOpenChat: (Long) -> Unit,
|
val navigator = LocalNavigator.current
|
||||||
onNewChat: () -> Unit,
|
val qrScanResult = LocalScanResult.current
|
||||||
) {
|
|
||||||
val clipboard = LocalClipboard.current
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val clipboardManager = LocalClipboard.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = viewModel.currentUser() ?: return
|
||||||
@@ -105,27 +106,24 @@ fun HomeScreen(
|
|||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
var showBottomSheet by remember { mutableStateOf(false) }
|
||||||
var isRefreshing 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) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.getChatRooms()
|
viewModel.getChatRooms()
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey ->
|
.onSuccess { pubkey ->
|
||||||
|
try {
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +143,7 @@ fun HomeScreen(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// QR Scanner
|
// QR Scanner
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -176,7 +174,7 @@ fun HomeScreen(
|
|||||||
state = rememberTooltipState(),
|
state = rememberTooltipState(),
|
||||||
) {
|
) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewChat,
|
onClick = { navigator.navigate(Screen.NewChat) },
|
||||||
expanded = expandedFab,
|
expanded = expandedFab,
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -253,7 +251,7 @@ fun HomeScreen(
|
|||||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
items(chatRooms.toList(), key = { it.id }) { room ->
|
||||||
ChatRoom(
|
ChatRoom(
|
||||||
room = room,
|
room = room,
|
||||||
onClick = { onOpenChat(room.id) }
|
onClick = { navigator.navigate(Screen.Chat(room.id)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,18 +316,20 @@ fun HomeScreen(
|
|||||||
) {
|
) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
dismissAndRun { navController.navigate(Screen.MyQr) }
|
scope.launch {
|
||||||
|
pubkey?.let {
|
||||||
|
val bech32 = it.toBech32()
|
||||||
|
val data = ClipData.newPlainText(bech32, bech32)
|
||||||
|
clipboardManager.setClipEntry(ClipEntry(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Text(text = shortPubkey)
|
Text(text = shortPubkey)
|
||||||
}
|
}
|
||||||
FilledIconButton(
|
FilledIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
dismissAndRun { navigator.navigate(Screen.MyQr) }
|
||||||
sheetState.hide()
|
|
||||||
showBottomSheet = false
|
|
||||||
navController.navigate(Screen.MyQr)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
shape = MaterialShapes.Square.toShape()
|
shape = MaterialShapes.Square.toShape()
|
||||||
) {
|
) {
|
||||||
@@ -398,11 +398,11 @@ fun ChatRoom(room: Room, onClick: () -> Unit) {
|
|||||||
fun BottomMenuList(
|
fun BottomMenuList(
|
||||||
onDismiss: (suspend () -> Unit) -> Unit
|
onDismiss: (suspend () -> Unit) -> Unit
|
||||||
) {
|
) {
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val defaultMenuList = listOf(
|
val defaultMenuList = listOf(
|
||||||
"Relay Management" to { navController.navigate(Screen.Relay) },
|
"Relay Management" to { navigator.navigate(Screen.Relay) },
|
||||||
"Spams & Blocks" to { },
|
"Spams & Blocks" to { },
|
||||||
"Contacts" to { },
|
"Contacts" to { },
|
||||||
"Settings" to { }
|
"Settings" to { }
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -40,7 +43,9 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -53,22 +58,24 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
import rust.nostr.sdk.NostrConnectUri
|
import rust.nostr.sdk.NostrConnectUri
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
import su.reya.coop.short
|
import su.reya.coop.short
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
isLoading: Boolean,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSave: (secret: String) -> Unit
|
onSave: (secret: String) -> Unit
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
|
val qrScanResult = LocalScanResult.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -82,19 +89,14 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
}.collectAsState(null)
|
}.collectAsState(null)
|
||||||
|
|
||||||
|
|
||||||
val profile = metadata?.asRecord()
|
val profile = metadata?.asRecord()
|
||||||
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
|
||||||
val picture = profile?.picture
|
val picture = profile?.picture
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
val isLoading by viewModel.isCreating.collectAsState()
|
||||||
val qrResult by savedStateHandle
|
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
runCatching {
|
runCatching {
|
||||||
if (result.startsWith("nsec")) {
|
if (result.startsWith("nsec")) {
|
||||||
Keys.parse(result)
|
Keys.parse(result)
|
||||||
@@ -106,8 +108,9 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
.onSuccess { it -> secret = result }
|
.onSuccess { it -> secret = result }
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +129,7 @@ fun ImportScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -134,7 +137,7 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -145,40 +148,44 @@ fun ImportScreen(
|
|||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
|
.imePadding(),
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
.weight(1f),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(120.dp)
|
.size(120.dp)
|
||||||
.clip(MaterialShapes.Pentagon.toShape()),
|
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Avatar(
|
Avatar(
|
||||||
picture = picture,
|
picture = picture,
|
||||||
description = "Profile picture",
|
description = "Profile picture",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = MaterialShapes.Pentagon.toShape(),
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = displayName,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.weight(1f, fill = false),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
@@ -186,6 +193,10 @@ fun ImportScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
@@ -200,6 +211,14 @@ fun ImportScreen(
|
|||||||
onValueChange = { secret = it },
|
onValueChange = { secret = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 4,
|
maxLines = 4,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
visualTransformation = PasswordVisualTransformation('*'),
|
visualTransformation = PasswordVisualTransformation('*'),
|
||||||
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.primaryFixed,
|
||||||
@@ -223,7 +242,8 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (pubkey == null) {
|
if (pubkey == null) {
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ import coop.composeapp.generated.resources.Res
|
|||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
import io.github.alexzhirkevich.qrose.rememberQrCodePainter
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MyQrScreen(
|
fun MyQrScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
val currentUser = viewModel.currentUser() ?: return
|
val currentUser = viewModel.currentUser() ?: return
|
||||||
@@ -41,7 +41,7 @@ fun MyQrScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ import coop.composeapp.generated.resources.ic_scanner
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.PublicKey
|
import rust.nostr.sdk.PublicKey
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
import su.reya.coop.Screen
|
import su.reya.coop.Screen
|
||||||
import su.reya.coop.shared.Avatar
|
import su.reya.coop.shared.Avatar
|
||||||
@@ -63,11 +64,10 @@ import su.reya.coop.short
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NewChatScreen(
|
fun NewChatScreen() {
|
||||||
onBack: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val navController = LocalNavController.current
|
val navigator = LocalNavigator.current
|
||||||
|
val qrScanResult = LocalScanResult.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
|
||||||
@@ -76,12 +76,6 @@ fun NewChatScreen(
|
|||||||
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
|
||||||
var query by remember { mutableStateOf("") }
|
var query by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
|
|
||||||
val qrResult by savedStateHandle
|
|
||||||
?.getStateFlow<String?>("qr_result", null)
|
|
||||||
?.collectAsState()
|
|
||||||
?: remember { mutableStateOf(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(query) {
|
LaunchedEffect(query) {
|
||||||
if (query.length >= 3) {
|
if (query.length >= 3) {
|
||||||
delay(500) // 500ms debounce
|
delay(500) // 500ms debounce
|
||||||
@@ -111,13 +105,19 @@ fun NewChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(qrResult) {
|
LaunchedEffect(qrScanResult.content) {
|
||||||
qrResult?.let { result ->
|
qrScanResult.content?.let { result ->
|
||||||
|
// Verify the content
|
||||||
runCatching { PublicKey.parse(result) }
|
runCatching { PublicKey.parse(result) }
|
||||||
.onSuccess { pubkey -> selectedReceivers.add(pubkey) }
|
.onSuccess { pubkey ->
|
||||||
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
|
selectedReceivers.add(pubkey)
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
println("Failed to parse QR: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the nav state
|
// Clear the nav state
|
||||||
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
|
qrScanResult.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ fun NewChatScreen(
|
|||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
),
|
),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -144,7 +144,7 @@ fun NewChatScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
|
IconButton(onClick = { navigator.navigate(Screen.Scan) }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_scanner),
|
painter = painterResource(Res.drawable.ic_scanner),
|
||||||
contentDescription = "Scanner"
|
contentDescription = "Scanner"
|
||||||
@@ -168,7 +168,7 @@ fun NewChatScreen(
|
|||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
expanded = false,
|
expanded = false,
|
||||||
icon = {
|
icon = {
|
||||||
@@ -259,7 +259,7 @@ fun NewChatScreen(
|
|||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
@@ -270,7 +270,7 @@ fun NewChatScreen(
|
|||||||
selectedReceivers = selectedReceivers,
|
selectedReceivers = selectedReceivers,
|
||||||
onContactClick = { pubkey ->
|
onContactClick = { pubkey ->
|
||||||
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||||
navController.navigate(Screen.Chat(roomId))
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -33,6 +36,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.toShape
|
import androidx.compose.material3.toShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -42,27 +46,36 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.ic_arrow_back
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
import coop.composeapp.generated.resources.ic_plus
|
import coop.composeapp.generated.resources.ic_plus
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun NewIdentityScreen(
|
fun NewIdentityScreen(
|
||||||
isLoading: Boolean,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var bio by remember { mutableStateOf("") }
|
var bio by remember { mutableStateOf("") }
|
||||||
var picture by remember { mutableStateOf<Uri?>(null) }
|
var picture by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
val isLoading by viewModel.isCreating.collectAsState()
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent()
|
contract = ActivityResultContracts.GetContent()
|
||||||
) { uri: Uri? ->
|
) { uri: Uri? ->
|
||||||
@@ -81,7 +94,7 @@ fun NewIdentityScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -94,16 +107,17 @@ fun NewIdentityScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxSize()
|
||||||
|
.padding(top = innerPadding.calculateTopPadding())
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = innerPadding.calculateTopPadding()),
|
.weight(1f),
|
||||||
verticalArrangement = Arrangement.Center,
|
contentAlignment = Alignment.Center
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -139,8 +153,8 @@ fun NewIdentityScreen(
|
|||||||
}
|
}
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.fillMaxWidth(),
|
.weight(1f, fill = true),
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||||
) {
|
) {
|
||||||
@@ -148,6 +162,10 @@ fun NewIdentityScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(24.dp)
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
@@ -161,7 +179,15 @@ fun NewIdentityScreen(
|
|||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 1,
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.primaryFixed,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
@@ -196,6 +222,14 @@ fun NewIdentityScreen(
|
|||||||
onValueChange = { bio = it },
|
onValueChange = { bio = it },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxLines = 3,
|
maxLines = 3,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
),
|
||||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||||
color = MaterialTheme.colorScheme.primaryFixed,
|
color = MaterialTheme.colorScheme.primaryFixed,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
@@ -218,7 +252,8 @@ fun NewIdentityScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
}
|
||||||
|
Spacer(modifier = Modifier.size(16.dp))
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
onSave(name, bio, picture)
|
onSave(name, bio, picture)
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
@@ -26,27 +27,69 @@ import androidx.compose.ui.graphics.ColorFilter
|
|||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.graphics.drawscope.translate
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coop.composeapp.generated.resources.Res
|
import coop.composeapp.generated.resources.Res
|
||||||
import coop.composeapp.generated.resources.coop
|
import coop.composeapp.generated.resources.coop
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
fun OnboardingScreen() {
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
val logoPainter = painterResource(Res.drawable.coop)
|
val logoPainter = painterResource(Res.drawable.coop)
|
||||||
|
val expressiveFont = getExpressiveFontFamily()
|
||||||
|
val annotatedText = buildAnnotatedString {
|
||||||
|
append("By using Coop, you agree to accept\nour ")
|
||||||
|
// Push "Terms of Use" link
|
||||||
|
pushLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url = "https://coop.free/terms",
|
||||||
|
styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
append("Terms of Use")
|
||||||
|
pop()
|
||||||
|
append(" and ")
|
||||||
|
// Push "Privacy Policy" link
|
||||||
|
pushLink(
|
||||||
|
LinkAnnotation.Url(
|
||||||
|
url = "https://coop.free/privacy",
|
||||||
|
styles = TextLinkStyles(
|
||||||
|
style = SpanStyle(
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
append("Privacy Policy")
|
||||||
|
pop()
|
||||||
|
append(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
content = { innerPadding ->
|
content = { innerPadding ->
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding())
|
|
||||||
) {
|
|
||||||
LogoRepeatingBackground(
|
LogoRepeatingBackground(
|
||||||
painter = logoPainter,
|
painter = logoPainter,
|
||||||
logosPerRow = 6,
|
logosPerRow = 6,
|
||||||
@@ -54,55 +97,71 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
|
|||||||
horizontalOffset = 0.5f
|
horizontalOffset = 0.5f
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(2f)
|
.fillMaxSize()
|
||||||
.fillMaxWidth(),
|
.padding(bottom = innerPadding.calculateBottomPadding() + 16.dp),
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
) {
|
||||||
// TODO: Add headline
|
Spacer(modifier = Modifier.weight(2f))
|
||||||
}
|
Surface(
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(bottom = innerPadding.calculateBottomPadding()),
|
.padding(24.dp),
|
||||||
contentAlignment = Alignment.BottomEnd,
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
shadowElevation = 4.dp,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = innerPadding.calculateBottomPadding()),
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onOpenNew,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.size(ButtonDefaults.LargeContainerHeight),
|
.padding(24.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Start messaging",
|
text = "Get Started",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
|
||||||
)
|
fontFamily = expressiveFont,
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.size(16.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = onOpenImport,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(ButtonDefaults.LargeContainerHeight),
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
),
|
),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Coop is a secure and easy to use messaging app. All your communications are encrypted and private by default.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { navigator.navigate(Screen.NewIdentity) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.size(ButtonDefaults.MediumContainerHeight),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Import identity",
|
text = "Start Messaging",
|
||||||
style = MaterialTheme.typography.titleLargeEmphasized,
|
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { navigator.navigate(Screen.Import) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Add an Existing Identity",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
text = annotatedText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +175,7 @@ fun LogoRepeatingBackground(
|
|||||||
rotationDegrees: Float = 0f,
|
rotationDegrees: Float = 0f,
|
||||||
horizontalOffset: Float = 0.5f
|
horizontalOffset: Float = 0.5f
|
||||||
) {
|
) {
|
||||||
val tintColor = MaterialTheme.colorScheme.primary
|
val tintColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
|
||||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
val canvasWidth = size.width
|
val canvasWidth = size.width
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package su.reya.coop.screens
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SegmentedListItem
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import coop.composeapp.generated.resources.ic_arrow_back
|
||||||
|
import coop.composeapp.generated.resources.ic_chat
|
||||||
|
import coop.composeapp.generated.resources.ic_share
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
import rust.nostr.sdk.PublicKey
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalNostrViewModel
|
||||||
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
import su.reya.coop.Screen
|
||||||
|
import su.reya.coop.shared.Avatar
|
||||||
|
import su.reya.coop.shared.getExpressiveFontFamily
|
||||||
|
import su.reya.coop.short
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ProfileScreen(pubkey: String) {
|
||||||
|
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
|
||||||
|
val metadata by metadataFlow.collectAsState(initial = null)
|
||||||
|
|
||||||
|
val profile = metadata?.asRecord()
|
||||||
|
val displayName = profile?.displayName ?: profile?.name ?: "No name"
|
||||||
|
val nip05 = profile?.nip05 ?: pubkey.short()
|
||||||
|
val picture = profile?.picture
|
||||||
|
val details = remember(profile) {
|
||||||
|
listOf(
|
||||||
|
"Username:" to (profile?.name ?: "None"),
|
||||||
|
"Website:" to (profile?.website ?: "None"),
|
||||||
|
"Lightning Address:" to (profile?.lud16 ?: "None"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
|
contentDescription = "Back"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.weight(1f),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(120.dp)
|
||||||
|
.clip(MaterialShapes.Cookie9Sided.toShape()),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Avatar(
|
||||||
|
picture = picture,
|
||||||
|
description = "Profile picture",
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
shape = MaterialShapes.Cookie9Sided.toShape(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(8.dp))
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleLargeEmphasized.copy(
|
||||||
|
fontFamily = getExpressiveFontFamily()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = nip05,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.size(24.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val roomId = viewModel.createChatRoom(listOf(pubkey))
|
||||||
|
navigator.navigate(Screen.Chat(roomId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.message?.let { snackbarHostState.showSnackbar(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_chat),
|
||||||
|
contentDescription = "New Chat"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Message",
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = {
|
||||||
|
val sendIntent = Intent().apply {
|
||||||
|
action = Intent.ACTION_SEND
|
||||||
|
putExtra(Intent.EXTRA_TEXT, pubkey.toBech32())
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(ButtonDefaults.MediumContainerHeight),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(Res.drawable.ic_share),
|
||||||
|
contentDescription = "Share"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Share",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.weight(1.5f),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
|
||||||
|
) {
|
||||||
|
details.forEachIndexed { index, (label, value) ->
|
||||||
|
SegmentedListItem(
|
||||||
|
onClick = { },
|
||||||
|
shapes = ListItemDefaults.segmentedShapes(
|
||||||
|
index = index,
|
||||||
|
count = details.size
|
||||||
|
),
|
||||||
|
content = { Text(label) },
|
||||||
|
supportingContent = { Text(value) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,14 +34,14 @@ import coop.composeapp.generated.resources.ic_arrow_back
|
|||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import rust.nostr.sdk.RelayMetadata
|
import rust.nostr.sdk.RelayMetadata
|
||||||
import rust.nostr.sdk.RelayUrl
|
import rust.nostr.sdk.RelayUrl
|
||||||
|
import su.reya.coop.LocalNavigator
|
||||||
import su.reya.coop.LocalNostrViewModel
|
import su.reya.coop.LocalNostrViewModel
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RelayScreen(
|
fun RelayScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
val viewModel = LocalNostrViewModel.current
|
val viewModel = LocalNostrViewModel.current
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ fun RelayScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
|
|||||||
@@ -30,21 +30,16 @@ import org.jetbrains.compose.resources.painterResource
|
|||||||
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
import org.publicvalue.multiplatform.qrcode.CameraPosition
|
||||||
import org.publicvalue.multiplatform.qrcode.CodeType
|
import org.publicvalue.multiplatform.qrcode.CodeType
|
||||||
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
|
||||||
import su.reya.coop.LocalNavController
|
import su.reya.coop.LocalNavigator
|
||||||
|
import su.reya.coop.LocalScanResult
|
||||||
import su.reya.coop.LocalSnackbarHostState
|
import su.reya.coop.LocalSnackbarHostState
|
||||||
|
|
||||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanScreen(
|
fun ScanScreen() {
|
||||||
onBack: () -> Unit
|
val navigator = LocalNavigator.current
|
||||||
) {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val snackbarHostState = LocalSnackbarHostState.current
|
val snackbarHostState = LocalSnackbarHostState.current
|
||||||
|
val qrScanResult = LocalScanResult.current
|
||||||
val onResult: (String) -> Unit = { result ->
|
|
||||||
navController.previousBackStackEntry?.savedStateHandle?.set("qr_result", result)
|
|
||||||
navController.popBackStack()
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
@@ -57,7 +52,7 @@ fun ScanScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = { navigator.goBack() }) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(Res.drawable.ic_arrow_back),
|
painter = painterResource(Res.drawable.ic_arrow_back),
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
@@ -76,8 +71,8 @@ fun ScanScreen(
|
|||||||
ScannerWithPermissions(
|
ScannerWithPermissions(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onScanned = {
|
onScanned = {
|
||||||
println("Scanned: $it");
|
qrScanResult.content = it
|
||||||
onResult(it)
|
navigator.goBack()
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
types = listOf(CodeType.QR),
|
types = listOf(CodeType.QR),
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package su.reya.coop.shared
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import coop.composeapp.generated.resources.PaytoneOne_Regular
|
||||||
|
import coop.composeapp.generated.resources.Res
|
||||||
|
import org.jetbrains.compose.resources.Font
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getExpressiveFontFamily() = FontFamily(
|
||||||
|
Font(Res.font.PaytoneOne_Regular, FontWeight.Normal)
|
||||||
|
)
|
||||||
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
BIN
composeApp/src/androidMain/res/drawable-hdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 551 B |
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
BIN
composeApp/src/androidMain/res/drawable-mdpi/ic_notification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 391 B |
Binary file not shown.
|
After Width: | Height: | Size: 727 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
22
composeApp/src/androidMain/res/drawable/coop.xml
Normal file
22
composeApp/src/androidMain/res/drawable/coop.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="200dp"
|
||||||
|
android:height="200dp"
|
||||||
|
android:viewportWidth="120"
|
||||||
|
android:viewportHeight="120">
|
||||||
|
|
||||||
|
<group
|
||||||
|
android:translateX="20"
|
||||||
|
android:translateY="20">
|
||||||
|
<path
|
||||||
|
android:pathData="M37.54,74C52.75,74 65.08,61.581 65.08,46.262C65.08,44.87 63.959,43.74 62.576,43.74H12.504C11.12,43.74 10,44.87 10,46.262C10,61.581 22.33,74 37.54,74Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M49.707,14.321L49.493,14.263L49.279,14.207L49.062,14.153L48.843,14.099L48.627,14.049L48.411,14L48.194,13.952L47.976,13.907L47.748,13.861L47.518,13.817L47.287,13.775L47.055,13.735L46.94,13.715L46.828,13.697L46.604,13.662L46.381,13.629L46.157,13.598L45.927,13.568L45.696,13.54L45.467,13.514L45.236,13.49L45.005,13.467L44.774,13.447L44.543,13.428L44.31,13.412L44.08,13.398L43.851,13.385L43.623,13.375L43.392,13.366L43.159,13.359L42.926,13.354L42.694,13.351L42.46,13.35H42.184L41.903,13.356L41.623,13.363L41.343,13.373L41.063,13.385L40.784,13.401L40.504,13.419L40.224,13.44L39.932,13.466L39.64,13.494L39.357,13.524L39.074,13.558L38.781,13.596L38.489,13.637L38.209,13.679L37.929,13.724L37.649,13.772L37.369,13.823L37.103,13.875L36.836,13.929L36.57,13.986L36.418,14.02L36.306,14.045L36.026,14.111L35.746,14.18L35.466,14.252L35.187,14.328L34.917,14.403L34.648,14.482L34.512,14.523L34.376,14.564L34.109,14.649L33.847,14.734L33.587,14.821C33.587,14.821 32.45,13.249 32.536,12.136C32.644,10.731 33.588,9.592 34.837,9.163C34.764,8.837 34.687,8.51 34.713,8.16C34.859,6.274 36.501,4.864 38.377,5.01C39.485,5.096 40.376,5.734 40.932,6.605C41.616,5.83 42.598,5.339 43.705,5.425C45.449,5.561 46.733,7.01 46.794,8.726C47.033,8.691 47.264,8.617 47.516,8.637C49.394,8.783 50.796,10.429 50.65,12.315C50.58,13.232 49.707,14.321 49.707,14.321Z"
|
||||||
|
android:fillColor="#000000"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M14.92,41.088C14.92,25.769 27.25,13.35 42.46,13.35C57.669,13.35 70,25.769 70,41.088C70,42.481 68.879,43.61 67.496,43.61H17.424C16.04,43.61 14.92,42.481 14.92,41.088ZM54.627,26.453C54.476,26.411 54.325,26.389 54.172,26.389C54.018,26.389 53.867,26.411 53.717,26.453C53.566,26.496 53.42,26.561 53.279,26.645C53.138,26.73 53.003,26.833 52.876,26.956C52.748,27.079 52.63,27.218 52.522,27.375C52.413,27.531 52.316,27.701 52.232,27.885C52.147,28.068 52.075,28.262 52.016,28.467C51.957,28.671 51.913,28.882 51.883,29.098C51.854,29.315 51.839,29.533 51.839,29.755C51.839,29.976 51.854,30.195 51.883,30.411C51.913,30.628 51.957,30.838 52.016,31.043C52.075,31.247 52.147,31.441 52.232,31.625C52.316,31.808 52.413,31.979 52.522,32.135C52.63,32.291 52.748,32.43 52.876,32.553C53.003,32.676 53.138,32.78 53.279,32.864C53.42,32.949 53.566,33.013 53.717,33.056C53.867,33.1 54.018,33.121 54.172,33.121C54.325,33.121 54.476,33.1 54.627,33.056C54.777,33.013 54.923,32.949 55.064,32.864C55.206,32.78 55.341,32.676 55.468,32.553C55.595,32.43 55.714,32.291 55.822,32.135C55.93,31.979 56.027,31.808 56.112,31.625C56.197,31.441 56.268,31.247 56.327,31.043C56.386,30.838 56.431,30.628 56.46,30.411C56.49,30.195 56.505,29.976 56.505,29.755C56.505,29.533 56.49,29.315 56.46,29.098C56.431,28.882 56.386,28.671 56.327,28.467C56.268,28.262 56.197,28.068 56.112,27.885C56.027,27.701 55.93,27.531 55.822,27.375C55.714,27.218 55.595,27.079 55.468,26.956C55.341,26.833 55.206,26.73 55.064,26.645C54.923,26.561 54.777,26.496 54.627,26.453Z"
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
4
composeApp/src/androidMain/res/values/colors.xml
Normal file
4
composeApp/src/androidMain/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="secondaryContainer">#F8FF37</color>
|
||||||
|
</resources>
|
||||||
9
composeApp/src/androidMain/res/values/themes.xml
Normal file
9
composeApp/src/androidMain/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
|
||||||
|
<item name="windowSplashScreenBackground">@color/secondaryContainer</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/coop</item>
|
||||||
|
<item name="postSplashScreenTheme">@android:style/Theme.Material.Light.NoActionBar</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -8,16 +8,20 @@ androidx-appcompat = "1.7.1"
|
|||||||
androidx-core = "1.18.0"
|
androidx-core = "1.18.0"
|
||||||
androidx-espresso = "3.7.0"
|
androidx-espresso = "3.7.0"
|
||||||
androidx-lifecycle = "2.10.0"
|
androidx-lifecycle = "2.10.0"
|
||||||
androidx-navigation = "2.8.8"
|
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
composeMultiplatform = "1.10.3"
|
androidx-splashscreen = "1.2.0"
|
||||||
|
composeMultiplatform = "1.11.0"
|
||||||
|
datastorePreferences = "1.2.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kotlin = "2.3.20"
|
kotlin = "2.3.21"
|
||||||
kotlinx-serialization = "1.8.0"
|
kotlinx-serialization = "1.11.0"
|
||||||
material3 = "1.10.0-alpha05"
|
material3 = "1.11.0-alpha07"
|
||||||
ktor = "3.4.3"
|
multiplatform-nav3-ui = "1.1.1"
|
||||||
|
ktor = "3.5.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastorePreferences" }
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
@@ -25,12 +29,13 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx
|
|||||||
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
androidx-testExt-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-testExt" }
|
||||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-espresso" }
|
||||||
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
|
||||||
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-splashscreen" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
|
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" }
|
||||||
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
|
||||||
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
|
||||||
|
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "androidx-lifecycle" }
|
||||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" }
|
||||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" }
|
||||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
|
||||||
@@ -43,6 +48,8 @@ ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "kto
|
|||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "multiplatform-nav3-ui" }
|
||||||
|
jetbrains-lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -25,15 +25,15 @@ kotlin {
|
|||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
|
||||||
implementation("su.reya:nostr-sdk-kmp:0.2.3")
|
|
||||||
implementation("com.squareup.okio:okio:3.16.2")
|
|
||||||
implementation(libs.ktor.client.core)
|
implementation(libs.ktor.client.core)
|
||||||
implementation(libs.ktor.client.websockets)
|
implementation(libs.ktor.client.websockets)
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||||
|
implementation("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")
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import io.ktor.client.plugins.websocket.WebSockets
|
|||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.HttpResponse
|
import io.ktor.client.statement.HttpResponse
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.supervisorScope
|
||||||
import rust.nostr.sdk.AckPolicy
|
import rust.nostr.sdk.AckPolicy
|
||||||
import rust.nostr.sdk.Alphabet
|
import rust.nostr.sdk.Alphabet
|
||||||
import rust.nostr.sdk.AsyncNostrSigner
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
@@ -62,9 +62,6 @@ object NostrManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Nostr {
|
class Nostr {
|
||||||
private val _isInitialized = MutableStateFlow(false)
|
|
||||||
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
|
||||||
|
|
||||||
var client: Client? = null
|
var client: Client? = null
|
||||||
private set
|
private set
|
||||||
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
var signer: UniversalSigner = UniversalSigner(Keys.generate())
|
||||||
@@ -76,9 +73,35 @@ class Nostr {
|
|||||||
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
var rumorMap: MutableMap<EventId, EventId> = mutableMapOf()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private val isInitialized = MutableStateFlow(false)
|
||||||
|
|
||||||
|
// Add these to the Nostr class
|
||||||
|
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||||
|
val newEvents = _newEvents.asSharedFlow()
|
||||||
|
|
||||||
|
private val _metadataUpdates =
|
||||||
|
MutableSharedFlow<Pair<PublicKey, Metadata>>(extraBufferCapacity = 100)
|
||||||
|
val metadataUpdates = _metadataUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _contactListUpdates = MutableSharedFlow<List<PublicKey>>(extraBufferCapacity = 100)
|
||||||
|
val contactListUpdates = _contactListUpdates.asSharedFlow()
|
||||||
|
|
||||||
|
private val _subscriptionClosed = MutableSharedFlow<Unit>(extraBufferCapacity = 10)
|
||||||
|
val subscriptionClosed = _subscriptionClosed.asSharedFlow()
|
||||||
|
|
||||||
|
suspend fun emitNewEvent(event: UnsignedEvent) = _newEvents.emit(event)
|
||||||
|
|
||||||
|
suspend fun emitSubscriptionClosed() = _subscriptionClosed.emit(Unit)
|
||||||
|
|
||||||
|
suspend fun emitMetadataUpdate(pubkey: PublicKey, metadata: Metadata) =
|
||||||
|
_metadataUpdates.emit(pubkey to metadata)
|
||||||
|
|
||||||
|
suspend fun emitContactListUpdate(contacts: List<PublicKey>) =
|
||||||
|
_contactListUpdates.emit(contacts)
|
||||||
|
|
||||||
suspend fun init(dbPath: String) {
|
suspend fun init(dbPath: String) {
|
||||||
try {
|
try {
|
||||||
if (_isInitialized.value) return
|
if (isInitialized.value) return
|
||||||
|
|
||||||
// Initialize the logger for nostr client
|
// Initialize the logger for nostr client
|
||||||
initLogger(LogLevel.DEBUG)
|
initLogger(LogLevel.DEBUG)
|
||||||
@@ -108,14 +131,14 @@ class Nostr {
|
|||||||
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
_isInitialized.value = true
|
isInitialized.value = true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
throw IllegalStateException("Failed to initialize Nostr client: ${e.message}", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun waitUntilInitialized() {
|
suspend fun waitUntilInitialized() {
|
||||||
_isInitialized.first { it }
|
isInitialized.first { it }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun connectBootstrapRelays() {
|
suspend fun connectBootstrapRelays() {
|
||||||
@@ -147,8 +170,6 @@ class Nostr {
|
|||||||
suspend fun setSigner(new: AsyncNostrSigner) {
|
suspend fun setSigner(new: AsyncNostrSigner) {
|
||||||
try {
|
try {
|
||||||
signer.switch(new)
|
signer.switch(new)
|
||||||
// Fetch metadata for current user
|
|
||||||
getUserMetadata()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
throw IllegalStateException("Failed to set signer: ${e.message}", e)
|
||||||
}
|
}
|
||||||
@@ -216,70 +237,15 @@ class Nostr {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
suspend fun handleNotifications(
|
||||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||||
onNewMessage: (UnsignedEvent) -> Unit,
|
onNewMessage: (UnsignedEvent) -> Unit,
|
||||||
onSubscriptionClose: () -> Unit,
|
onSubscriptionClose: () -> Unit,
|
||||||
) = coroutineScope {
|
) = supervisorScope {
|
||||||
val now = Timestamp.now()
|
val now = Timestamp.now()
|
||||||
val processedEvent = mutableSetOf<EventId>()
|
val processedEvent = mutableSetOf<EventId>()
|
||||||
val notifications = client?.notifications() ?: return@coroutineScope
|
val notifications = client?.notifications() ?: return@supervisorScope
|
||||||
|
|
||||||
var eoseTrackerJob: Job? = null
|
var eoseTrackerJob: Job? = null
|
||||||
|
|
||||||
@@ -293,7 +259,6 @@ class Nostr {
|
|||||||
when (val message = notification.message.asEnum()) {
|
when (val message = notification.message.asEnum()) {
|
||||||
is RelayMessageEnum.EventMsg -> {
|
is RelayMessageEnum.EventMsg -> {
|
||||||
val event = message.event
|
val event = message.event
|
||||||
val id = message.subscriptionId
|
|
||||||
|
|
||||||
// Prevent processing duplicate events
|
// Prevent processing duplicate events
|
||||||
if (processedEvent.contains(event.id())) continue
|
if (processedEvent.contains(event.id())) continue
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import rust.nostr.sdk.AsyncNostrSigner
|
||||||
import rust.nostr.sdk.EventBuilder
|
import rust.nostr.sdk.EventBuilder
|
||||||
import rust.nostr.sdk.EventId
|
import rust.nostr.sdk.EventId
|
||||||
import rust.nostr.sdk.Keys
|
import rust.nostr.sdk.Keys
|
||||||
@@ -32,14 +34,14 @@ import rust.nostr.sdk.UnsignedEvent
|
|||||||
import su.reya.coop.blossom.BlossomClient
|
import su.reya.coop.blossom.BlossomClient
|
||||||
import su.reya.coop.storage.SecretStorage
|
import su.reya.coop.storage.SecretStorage
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class NostrViewModel(
|
class NostrViewModel(
|
||||||
private val nostr: Nostr,
|
private val nostr: Nostr,
|
||||||
private val secretStore: SecretStorage
|
private val secretStore: SecretStorage
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _emptySecret = MutableStateFlow<Boolean?>(null)
|
private val _signerRequired = MutableStateFlow<Boolean?>(null)
|
||||||
val emptySecret = _emptySecret.asStateFlow()
|
val signerRequired = _signerRequired.asStateFlow()
|
||||||
|
|
||||||
private val _isCreating = MutableStateFlow(false)
|
private val _isCreating = MutableStateFlow(false)
|
||||||
val isCreating = _isCreating.asStateFlow()
|
val isCreating = _isCreating.asStateFlow()
|
||||||
@@ -70,11 +72,20 @@ class NostrViewModel(
|
|||||||
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
private val seenPublicKeys = mutableSetOf<PublicKey>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
startNotificationHandler()
|
// Check local stored secret (secret key or bunker)
|
||||||
startMetadataBatchHandler()
|
|
||||||
getCacheMetadata()
|
|
||||||
login()
|
login()
|
||||||
|
|
||||||
|
// Observe the signer state and verify the relay list
|
||||||
observeSignerAndCheckRelays()
|
observeSignerAndCheckRelays()
|
||||||
|
|
||||||
|
// Get all local stored metadata
|
||||||
|
getCacheMetadata()
|
||||||
|
|
||||||
|
// Observe new events from the Nostr client
|
||||||
|
runObserver()
|
||||||
|
|
||||||
|
// Wait and merge metadata requests into a single batch
|
||||||
|
runMetadataBatching()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
@@ -94,35 +105,53 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startNotificationHandler() {
|
private fun runObserver() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Wait until the client is ready
|
// Observe new messages
|
||||||
nostr.waitUntilInitialized()
|
launch {
|
||||||
|
nostr.newEvents.collect { event ->
|
||||||
|
val roomId = event.roomId()
|
||||||
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == roomId }
|
||||||
|
|
||||||
nostr.handleNotifications(
|
if (existingRoom == null) {
|
||||||
onMetadataUpdate = { pubkey, metadata ->
|
val currentUser = nostr.signer.currentUser
|
||||||
updateMetadata(pubkey, metadata)
|
if (currentUser != null) {
|
||||||
},
|
val newRoom = Room.new(event, currentUser)
|
||||||
onContactListUpdate = { contactList ->
|
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
|
||||||
_contactList.value = contactList.toSet()
|
|
||||||
},
|
|
||||||
onSubscriptionClose = {
|
|
||||||
getChatRooms()
|
|
||||||
|
|
||||||
if (!_isPartialProcessedGiftWrap.value) {
|
|
||||||
_isPartialProcessedGiftWrap.value = true
|
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
onNewMessage = { event ->
|
updateRoomList(roomId, event)
|
||||||
viewModelScope.launch {
|
}
|
||||||
|
|
||||||
_newEvents.emit(event)
|
_newEvents.emit(event)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
// Observe metadata updates
|
||||||
|
launch {
|
||||||
|
nostr.metadataUpdates.collect { (pubkey, metadata) ->
|
||||||
|
updateMetadata(pubkey, metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startMetadataBatchHandler() {
|
// Observe contact list updates
|
||||||
|
launch {
|
||||||
|
nostr.contactListUpdates.collect { contacts ->
|
||||||
|
_contactList.value = contacts.toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observes subscription close
|
||||||
|
launch {
|
||||||
|
nostr.subscriptionClosed.collect {
|
||||||
|
getChatRooms()
|
||||||
|
_isPartialProcessedGiftWrap.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runMetadataBatching() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Wait until the client is ready
|
// Wait until the client is ready
|
||||||
nostr.waitUntilInitialized()
|
nostr.waitUntilInitialized()
|
||||||
@@ -163,7 +192,9 @@ class NostrViewModel(
|
|||||||
|
|
||||||
val results = nostr.getAllCacheMetadata()
|
val results = nostr.getAllCacheMetadata()
|
||||||
results.forEach { (pubkey, metadata) ->
|
results.forEach { (pubkey, metadata) ->
|
||||||
|
// Update the metadata state
|
||||||
updateMetadata(pubkey, metadata)
|
updateMetadata(pubkey, metadata)
|
||||||
|
// Update seenPublicKeys to avoid duplicate requests
|
||||||
seenPublicKeys.add(pubkey)
|
seenPublicKeys.add(pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,39 +202,26 @@ class NostrViewModel(
|
|||||||
|
|
||||||
private fun login() {
|
private fun login() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// Wait until the client is ready
|
try {
|
||||||
nostr.waitUntilInitialized()
|
|
||||||
|
|
||||||
// Get user's signer secret
|
|
||||||
val secret = secretStore.get("user_signer")
|
val secret = secretStore.get("user_signer")
|
||||||
|
|
||||||
// If no secret is found, show onboarding screen
|
if (secret == null) {
|
||||||
when (secret) {
|
_signerRequired.value = true
|
||||||
null -> {
|
|
||||||
_emptySecret.value = true
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> _emptySecret.value = false
|
runCatching {
|
||||||
|
val signer = createSigner(secret)
|
||||||
|
nostr.setSigner(signer)
|
||||||
|
}.onSuccess {
|
||||||
|
_signerRequired.value = false
|
||||||
|
}.onFailure { e ->
|
||||||
|
showError("Login failed: ${e.message}")
|
||||||
|
_signerRequired.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Login failed: ${e.message}")
|
||||||
}
|
_signerRequired.value = true
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Invalid secret format: $secret")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -214,15 +232,29 @@ class NostrViewModel(
|
|||||||
val pubkey = nostr.signer.currentUser
|
val pubkey = nostr.signer.currentUser
|
||||||
|
|
||||||
if (pubkey != null) {
|
if (pubkey != null) {
|
||||||
|
// Get chat rooms
|
||||||
|
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||||
|
if (rooms.isNotEmpty()) {
|
||||||
|
_chatRooms.value = rooms
|
||||||
|
_isPartialProcessedGiftWrap.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all metadata for the current user
|
||||||
|
nostr.getUserMetadata()
|
||||||
|
|
||||||
|
// Small delay to ensure all relays are connected
|
||||||
delay(3000)
|
delay(3000)
|
||||||
|
|
||||||
|
// Check if the relay list is empty
|
||||||
val relays = nostr.getMsgRelays(pubkey)
|
val relays = nostr.getMsgRelays(pubkey)
|
||||||
if (relays.isEmpty()) {
|
if (relays.isEmpty()) {
|
||||||
_isRelayListEmpty.value = true
|
_isRelayListEmpty.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(1000)
|
delay(500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +287,7 @@ class NostrViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
secretStore.clear("user_signer")
|
secretStore.clear("user_signer")
|
||||||
nostr.signer.switch(Keys.generate())
|
nostr.signer.switch(Keys.generate())
|
||||||
_emptySecret.value = true
|
_signerRequired.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +310,20 @@ class NostrViewModel(
|
|||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun createSigner(secret: String): AsyncNostrSigner {
|
||||||
|
return when {
|
||||||
|
secret.startsWith("nsec1") -> Keys.parse(secret)
|
||||||
|
secret.startsWith("bunker://") -> {
|
||||||
|
val appKeys = getOrInitAppKeys()
|
||||||
|
val bunker = NostrConnectUri.parse(secret)
|
||||||
|
val timeout = 50.seconds // or Duration.parse("50s")
|
||||||
|
NostrConnect(uri = bunker, appKeys, timeout, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Invalid secret format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun createIdentity(
|
fun createIdentity(
|
||||||
name: String,
|
name: String,
|
||||||
bio: String?,
|
bio: String?,
|
||||||
@@ -324,7 +370,7 @@ class NostrViewModel(
|
|||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
|
|
||||||
// Set an empty secret state
|
// Set an empty secret state
|
||||||
_emptySecret.value = false
|
_signerRequired.value = false
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showError("Error: ${e.message}")
|
showError("Error: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -332,48 +378,25 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun verifyIdentity(secret: String): PublicKey? {
|
suspend fun verifyIdentity(secret: String): PublicKey? {
|
||||||
if (secret.startsWith("nsec1")) {
|
return runCatching {
|
||||||
val keys = Keys.parse(secret)
|
val signer = createSigner(secret)
|
||||||
return keys.publicKey()
|
if (secret.startsWith("bunker://")) {
|
||||||
} 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.")
|
showError("Please approve the connection.")
|
||||||
|
|
||||||
return remote.getPublicKeyAsync()
|
|
||||||
} else {
|
|
||||||
throw IllegalArgumentException("Invalid secret: $secret")
|
|
||||||
}
|
}
|
||||||
|
signer.getPublicKeyAsync()
|
||||||
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun importIdentity(secret: String) {
|
fun importIdentity(secret: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
if (secret.startsWith("nsec1")) {
|
runCatching {
|
||||||
val keys = Keys.parse(secret)
|
val signer = createSigner(secret)
|
||||||
nostr.setSigner(keys)
|
nostr.setSigner(signer)
|
||||||
secretStore.set("user_signer", secret)
|
secretStore.set("user_signer", secret)
|
||||||
// Set an empty secret state
|
}.onSuccess {
|
||||||
_emptySecret.value = false
|
_signerRequired.value = false
|
||||||
} else if (secret.startsWith("bunker://")) {
|
}.onFailure { e ->
|
||||||
try {
|
showError(e.message ?: "Invalid Secret or Bunker URI")
|
||||||
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.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,20 +429,39 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun createChatRoom(to: List<PublicKey>): Long {
|
fun createChatRoom(to: List<PublicKey>): Long {
|
||||||
|
try {
|
||||||
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
|
||||||
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
|
||||||
|
|
||||||
|
val currentUser = nostr.signer.currentUser!!
|
||||||
|
|
||||||
// Construct the rumor event
|
// Construct the rumor event
|
||||||
val rumor = EventBuilder
|
val rumor = EventBuilder
|
||||||
.privateMsgRumor(to.first(), "")
|
.privateMsgRumor(to.first(), "")
|
||||||
.tags(to.map { Tag.publicKey(it) })
|
.tags(to.map { Tag.publicKey(it) })
|
||||||
.build(nostr.signer.currentUser!!)
|
.build(currentUser)
|
||||||
|
|
||||||
|
// Check if the room already exists
|
||||||
|
val id = rumor.roomId()
|
||||||
|
val existingRoom = _chatRooms.value.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
// If the room already exists, return its ID
|
||||||
|
if (existingRoom != null) {
|
||||||
|
return existingRoom.id
|
||||||
|
}
|
||||||
|
|
||||||
// Create a room from the rumor event
|
// Create a room from the rumor event
|
||||||
val room = Room.new(rumor, nostr.signer.currentUser!!)
|
val room = Room.new(rumor, currentUser)
|
||||||
_chatRooms.value += room
|
|
||||||
|
// Update the chat rooms state
|
||||||
|
_chatRooms.update { currentRooms ->
|
||||||
|
currentRooms + room
|
||||||
|
}
|
||||||
|
|
||||||
return room.id
|
return room.id
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("Failed to create room: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getChatRoom(id: Long): Room {
|
fun getChatRoom(id: Long): Room {
|
||||||
@@ -429,10 +471,12 @@ class NostrViewModel(
|
|||||||
|
|
||||||
fun getChatRooms() {
|
fun getChatRooms() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
val rooms = nostr.getChatRooms() ?: emptySet()
|
||||||
_chatRooms.value = nostr.getChatRooms() ?: emptySet()
|
_chatRooms.update { currentRooms ->
|
||||||
} catch (e: Exception) {
|
val virtualRooms = currentRooms.filter { local ->
|
||||||
showError("Error: ${e.message}")
|
rooms.none { db -> db.id == local.id }
|
||||||
|
}
|
||||||
|
rooms + virtualRooms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -468,6 +512,9 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
fun sendMessage(roomId: Long, message: String, replies: List<EventId> = emptyList()) {
|
||||||
|
if (message.isEmpty()) {
|
||||||
|
showError("Message cannot be empty")
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val room = getChatRoom(roomId)
|
val room = getChatRoom(roomId)
|
||||||
@@ -499,13 +546,18 @@ class NostrViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
private fun updateRoomList(roomId: Long, newMessage: UnsignedEvent) {
|
||||||
_chatRooms.value = _chatRooms.value.map { room ->
|
_chatRooms.update { currentRooms ->
|
||||||
|
currentRooms.map { room ->
|
||||||
if (room.id == roomId) {
|
if (room.id == roomId) {
|
||||||
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt())
|
room.copy(
|
||||||
|
lastMessage = newMessage.content(),
|
||||||
|
createdAt = newMessage.createdAt()
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
room
|
room
|
||||||
}
|
}
|
||||||
}.toSet()
|
}.sortedDescending().toSet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun searchByAddress(query: String): PublicKey? {
|
suspend fun searchByAddress(query: String): PublicKey? {
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ data class Room(
|
|||||||
val subject = rumor.tags().find(TagKind.Subject)?.content()
|
val subject = rumor.tags().find(TagKind.Subject)?.content()
|
||||||
|
|
||||||
// Collect the author's public key and all public keys from tags
|
// Collect the author's public key and all public keys from tags
|
||||||
// Also remove the user's public key from the list, current user is always a member
|
|
||||||
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
val pubkeys: MutableSet<PublicKey> = mutableSetOf()
|
||||||
pubkeys.add(rumor.author())
|
pubkeys.add(rumor.author())
|
||||||
pubkeys.addAll(rumor.tags().publicKeys())
|
pubkeys.addAll(rumor.tags().publicKeys())
|
||||||
|
// Also remove the user's public key from the list, current user is always a member
|
||||||
pubkeys.remove(userPubkey)
|
pubkeys.remove(userPubkey)
|
||||||
|
|
||||||
// Create a new Room instance
|
// Create a new Room instance
|
||||||
|
|||||||
Reference in New Issue
Block a user