4 Commits

Author SHA1 Message Date
b88674d6e2 chore: bump version
Some checks failed
Build and Release / build (push) Has been cancelled
2026-05-29 13:57:51 +07:00
e9eb071208 feat: implement basic notification (#6)
Reviewed-on: #6
2026-05-29 06:56:47 +00:00
a2a4433a9d feat: add profile screen (#5)
Some checks failed
Build and Release / build (push) Has been cancelled
Reviewed-on: #5
2026-05-27 06:22:14 +00:00
1c08525dfc feat: new onboarding screens (#4)
Changelog:
- Add a custom splash screen
- Redesign the onboarding screen
- Improve UX for the new and import identity screens

Reviewed-on: #4
2026-05-26 02:04:07 +00:00
32 changed files with 1015 additions and 422 deletions

View File

@@ -21,6 +21,7 @@ kotlin {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.core.splashscreen)
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("su.reya:nostr-sdk-kmp:0.2.3")
@@ -66,7 +67,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 {

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,473 799.5,465.5Q799,458 799,453Q794,482 772,501Q750,520 720,520L640,520Q607,520 583.5,496.5Q560,473 560,440L560,400L400,400L400,320Q400,287 423.5,263.5Q447,240 480,240L520,240L520,240Q520,217 532.5,199.5Q545,182 563,171Q543,166 522.5,163Q502,160 480,160Q346,160 253,253Q160,346 160,480Q160,480 160,480Q160,480 160,480L360,480Q426,480 473,527Q520,574 520,640L520,680L400,680L400,790Q420,795 439.5,797.5Q459,800 480,800Z" />
</vector>

View File

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

View File

@@ -39,14 +39,13 @@ 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.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navDeepLink
import androidx.navigation.toRoute import androidx.navigation.toRoute
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
@@ -54,6 +53,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
@@ -71,7 +71,7 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
@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 navController = rememberNavController()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -80,17 +80,15 @@ fun App() {
// Snackbar // Snackbar
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
// Initialize Nostr View Model and Secret Store
val secretStore = remember { SecretStore(context) }
val viewModel: NostrViewModel = viewModel { NostrViewModel(NostrManager.instance, secretStore) }
// Enabled the dynamic color scheme // 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 (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
// When dark mode is enabled, use the dark color scheme
darkMode -> darkColorScheme() darkMode -> darkColorScheme()
// Fallback to the light color scheme
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
@@ -110,21 +108,107 @@ fun App() {
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController, LocalNavController provides navController,
) { ) {
val emptySecret by viewModel.emptySecret.collectAsState(initial = null) val signerRequired by viewModel.signerRequired.collectAsState(initial = null)
val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState() val isRelayListEmpty by viewModel.isRelayListEmpty.collectAsState()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
LaunchedEffect(emptySecret) { LaunchedEffect(signerRequired) {
// Navigate to the home screen if the secret is already set // Navigate to the home screen if the secret is already set
if (emptySecret == false) { if (signerRequired == false) {
navController.navigate(Screen.Home) { navController.navigate(Screen.Home) {
popUpTo(Screen.Onboarding) { inclusive = true } popUpTo(Screen.Onboarding) { inclusive = true }
} }
} }
} }
// Show loading screen while initializing // Keep the splash screen visible until the secret check is complete
if (emptySecret == null) return@CompositionLocalProvider if (signerRequired == null) {
return@CompositionLocalProvider
}
NavHost(
navController = navController,
startDestination = if (signerRequired!!) Screen.Onboarding else Screen.Home
) {
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>(
deepLinks = listOf(
navDeepLink<Screen.Chat>(basePath = "coop://chat")
)
) { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(
id = chat.id,
onBack = { navController.popBackStack() },
)
}
composable<Screen.Profile> { backStackEntry ->
val profile: Screen.Profile = backStackEntry.toRoute()
ProfileScreen(
pubkey = profile.pubkey,
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() },
)
}
}
// 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) {
@@ -180,79 +264,6 @@ 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() },
)
}
}
} }
} }
} }

View File

@@ -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)
}
} }

View File

@@ -9,6 +9,9 @@ sealed interface 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

View File

@@ -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
@@ -31,7 +33,8 @@ class NostrForegroundService : Service() {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createNotificationChannel() createNotificationChannel()
val notification = createNotification("Connecting to Nostr...")
val notification = createNotification()
startForeground(1, notification) startForeground(1, notification)
serviceScope.launch { serviceScope.launch {
@@ -43,11 +46,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(
if (!isUserInApp()) { onMetadataUpdate = { pubkey, metadata ->
showNewMessageNotification(event.content()) serviceScope.launch { nostr.emitMetadataUpdate(pubkey, metadata) }
},
onContactListUpdate = { contacts ->
serviceScope.launch { nostr.emitContactListUpdate(contacts) }
},
onSubscriptionClose = {
serviceScope.launch { nostr.emitSubscriptionClosed() }
},
onNewMessage = { event ->
serviceScope.launch {
if (!isUserInApp()) {
showNewMessageNotification(event.roomId(), event.content())
}
nostr.emitNewEvent(event)
}
} }
} )
} catch (e: Exception) { } catch (e: Exception) {
println("Failed to start Nostr in background: ${e.message}") println("Failed to start Nostr in background: ${e.message}")
} }
@@ -58,30 +75,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)
"nostr_service",
"Nostr Background Service", val serviceChannel = NotificationChannel(
"nostr_service_silent",
"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")
}
return builder.build()
} }
private fun showNewMessageNotification(message: String) { private fun showNewMessageNotification(roomId: Long, message: String) {
val notification = NotificationCompat.Builder(this, "nostr_service") val deepLinkUri = "coop://chat/$roomId".toUri()
.setContentTitle("New Message")
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)
} }

View File

@@ -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.LocalNavController
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
@@ -66,6 +71,7 @@ fun ChatScreen(
onBack: () -> Unit, onBack: () -> Unit,
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -87,19 +93,16 @@ fun ChatScreen(
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)
@@ -119,7 +122,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 ->
@@ -127,6 +130,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++
} }
} }
} }
@@ -143,7 +149,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 ->
navController.navigate(Screen.Profile(pubkey.toBech32()))
}
}
) {
if (loading) { if (loading) {
LoadingIndicator( LoadingIndicator(
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
@@ -163,11 +176,21 @@ fun ChatScreen(
} }
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { BadgedBox(
Icon( badge = {
painter = painterResource(Res.drawable.ic_arrow_back), if (newOtherMessages > 0) {
contentDescription = "Back" Badge {
) Text(newOtherMessages.toString())
}
}
}
) {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(

View File

@@ -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,8 @@ 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.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
@@ -86,6 +89,7 @@ fun HomeScreen(
) { ) {
val navController = LocalNavController.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
@@ -322,18 +326,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 { navController.navigate(Screen.MyQr) }
sheetState.hide()
showBottomSheet = false
navController.navigate(Screen.MyQr)
}
}, },
shape = MaterialShapes.Square.toShape() shape = MaterialShapes.Square.toShape()
) { ) {

View File

@@ -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
@@ -58,6 +63,7 @@ import su.reya.coop.LocalNostrViewModel
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)
@@ -69,6 +75,7 @@ fun ImportScreen(
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navController = LocalNavController.current
val focusManager = LocalFocusManager.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -145,40 +152,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,44 +197,57 @@ fun ImportScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp) .padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Column(
text = "Enter your Secret Key or Bunker URI:", modifier = Modifier
style = MaterialTheme.typography.titleMediumEmphasized.copy( .weight(1f)
fontWeight = FontWeight.SemiBold, .verticalScroll(rememberScrollState()),
), verticalArrangement = Arrangement.spacedBy(16.dp)
) ) {
BasicTextField( Text(
value = secret, text = "Enter your Secret Key or Bunker URI:",
onValueChange = { secret = it }, style = MaterialTheme.typography.titleMediumEmphasized.copy(
modifier = Modifier.fillMaxWidth(), fontWeight = FontWeight.SemiBold,
maxLines = 4, ),
visualTransformation = PasswordVisualTransformation('*'), )
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy( BasicTextField(
color = MaterialTheme.colorScheme.primaryFixed, value = secret,
fontWeight = FontWeight.SemiBold, onValueChange = { secret = it },
), modifier = Modifier.fillMaxWidth(),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary), maxLines = 4,
decorationBox = { innerTextField -> keyboardOptions = KeyboardOptions(
Box(contentAlignment = Alignment.CenterStart) { imeAction = ImeAction.Done,
if (secret.isEmpty()) { ),
Text( keyboardActions = KeyboardActions(
"bunker://", onDone = {
style = MaterialTheme.typography.bodyMediumEmphasized.copy( focusManager.clearFocus()
fontWeight = FontWeight.SemiBold, }
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy( visualTransformation = PasswordVisualTransformation('*'),
alpha = 0.5f textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
) color = MaterialTheme.colorScheme.primaryFixed,
) fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (secret.isEmpty()) {
Text(
"bunker://",
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
} }
innerTextField()
} }
} )
) }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
if (pubkey == null) { if (pubkey == null) {

View File

@@ -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
@@ -42,7 +45,9 @@ 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
@@ -59,6 +64,8 @@ fun NewIdentityScreen(
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
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) }
@@ -95,15 +102,16 @@ fun NewIdentityScreen(
}, },
content = { innerPadding -> content = { innerPadding ->
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding())
.imePadding(),
) { ) {
Column( Box(
modifier = Modifier modifier = Modifier
.weight(1f)
.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 +147,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,77 +156,98 @@ fun NewIdentityScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(24.dp) .padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Column(
text = "What others should call you?", modifier = Modifier
style = MaterialTheme.typography.titleLargeEmphasized.copy( .weight(1f)
fontWeight = FontWeight.SemiBold, .verticalScroll(rememberScrollState()),
), verticalArrangement = Arrangement.spacedBy(16.dp)
) ) {
BasicTextField( Text(
value = name, text = "What others should call you?",
onValueChange = { name = it }, style = MaterialTheme.typography.titleLargeEmphasized.copy(
modifier = Modifier.fillMaxWidth(), fontWeight = FontWeight.SemiBold,
maxLines = 1, ),
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy( )
color = MaterialTheme.colorScheme.primaryFixed, BasicTextField(
fontWeight = FontWeight.SemiBold, value = name,
), onValueChange = { name = it },
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary), modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField -> singleLine = true,
Box(contentAlignment = Alignment.CenterStart) { keyboardOptions = KeyboardOptions(
if (name.isEmpty()) { imeAction = ImeAction.Done,
Text( ),
"Alice", keyboardActions = KeyboardActions(
style = MaterialTheme.typography.headlineLargeEmphasized.copy( onDone = {
fontWeight = FontWeight.SemiBold, focusManager.clearFocus()
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
} }
innerTextField() ),
} textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
} color = MaterialTheme.colorScheme.primaryFixed,
) fontWeight = FontWeight.SemiBold,
Spacer(modifier = Modifier.size(8.dp)) ),
Text( cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
text = "Your bio (optional)", decorationBox = { innerTextField ->
style = MaterialTheme.typography.titleLargeEmphasized.copy( Box(contentAlignment = Alignment.CenterStart) {
fontWeight = FontWeight.SemiBold, if (name.isEmpty()) {
), Text(
) "Alice",
BasicTextField( style = MaterialTheme.typography.headlineLargeEmphasized.copy(
value = bio, fontWeight = FontWeight.SemiBold,
onValueChange = { bio = it }, ),
modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
maxLines = 3, alpha = 0.5f
textStyle = MaterialTheme.typography.bodyLarge.copy( )
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
) )
) }
innerTextField()
} }
innerTextField()
} }
} )
) Spacer(modifier = Modifier.size(8.dp))
Spacer(modifier = Modifier.weight(1f)) Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
}
),
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
}
Spacer(modifier = Modifier.size(16.dp))
Button( Button(
onClick = { onClick = {
onSave(name, bio, picture) onSave(name, bio, picture)

View File

@@ -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,65 @@ 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.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.shared.getExpressiveFontFamily
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.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 +93,71 @@ fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) {
horizontalOffset = 0.5f horizontalOffset = 0.5f
) )
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding() + 16.dp),
) { ) {
Box( Spacer(modifier = Modifier.weight(2f))
Surface(
modifier = Modifier modifier = Modifier
.weight(2f)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
// TODO: Add headline
}
Box(
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()), modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
) { ) {
Text(
text = "Get Started",
style = MaterialTheme.typography.headlineSmallEmphasized.copy(
fontFamily = expressiveFont,
),
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Coop is a secure and easy to use messaging app. All your communications are encrypted and private by default.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(24.dp))
Button( Button(
onClick = onOpenNew, onClick = onOpenNew,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.size(ButtonDefaults.LargeContainerHeight), .size(ButtonDefaults.MediumContainerHeight),
) { ) {
Text( Text(
text = "Start messaging", text = "Start Messaging",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(8.dp))
FilledTonalButton( OutlinedButton(
onClick = onOpenImport, onClick = onOpenImport,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.LargeContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer
),
) { ) {
Text( Text(
text = "Import identity", text = "Add an Existing Identity",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleMedium,
) )
} }
} }
} }
Text(
text = annotatedText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
} }
} }
} }
@@ -116,7 +171,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

View File

@@ -0,0 +1,243 @@
package su.reya.coop.screens
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.LocalNavController
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ProfileScreen(
onBack: () -> Unit,
pubkey: String
) {
val pubkey = runCatching { PublicKey.parse(pubkey) }.getOrNull() ?: return
val context = LocalContext.current
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.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 = onBack) {
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))
navController.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) },
)
}
}
}
}
}
)
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.10.0" androidx-lifecycle = "2.10.0"
androidx-navigation = "2.9.8" androidx-navigation = "2.9.8"
androidx-testExt = "1.3.0" androidx-testExt = "1.3.0"
androidx-splashscreen = "1.2.0"
composeMultiplatform = "1.11.0" composeMultiplatform = "1.11.0"
datastorePreferences = "1.2.1" datastorePreferences = "1.2.1"
junit = "4.13.2" junit = "4.13.2"
@@ -28,6 +29,7 @@ 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" } 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" }

View File

@@ -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

View File

@@ -39,8 +39,8 @@ 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()
@@ -71,11 +71,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() {
@@ -95,35 +104,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
if (currentUser != null) {
val newRoom = Room.new(event, currentUser)
_chatRooms.update { (it + newRoom).sortedDescending().toSet() }
}
} else {
updateRoomList(roomId, event)
}
_newEvents.emit(event)
}
}
// Observe metadata updates
launch {
nostr.metadataUpdates.collect { (pubkey, metadata) ->
updateMetadata(pubkey, metadata) updateMetadata(pubkey, metadata)
}, }
onContactListUpdate = { contactList -> }
_contactList.value = contactList.toSet()
},
onSubscriptionClose = {
getChatRooms()
if (!_isPartialProcessedGiftWrap.value) { // Observe contact list updates
_isPartialProcessedGiftWrap.value = true launch {
} nostr.contactListUpdates.collect { contacts ->
}, _contactList.value = contacts.toSet()
onNewMessage = { event -> }
viewModelScope.launch { }
_newEvents.emit(event)
} // Observes subscription close
}, launch {
) nostr.subscriptionClosed.collect {
getChatRooms()
_isPartialProcessedGiftWrap.value = true
}
}
} }
} }
private fun startMetadataBatchHandler() { private fun runMetadataBatching() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready // Wait until the client is ready
nostr.waitUntilInitialized() nostr.waitUntilInitialized()
@@ -164,7 +191,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)
} }
} }
@@ -172,22 +201,18 @@ class NostrViewModel(
private fun login() { private fun login() {
viewModelScope.launch { viewModelScope.launch {
// Wait until the client is ready
nostr.waitUntilInitialized()
// Get user's signer secret // 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 no secret is found, show onboarding screen
when (secret) { if (secret == null) {
null -> { _signerRequired.value = true
_emptySecret.value = true return@launch
return@launch
}
else -> _emptySecret.value = false
} }
// Update the empty secret state
_signerRequired.value = false
// Handle different signer types // Handle different signer types
if (secret.startsWith("nsec1")) { if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret) val keys = Keys.parse(secret)
@@ -197,8 +222,7 @@ class NostrViewModel(
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = val remote = NostrConnect(uri = bunker, appKeys, timeout, opts = null)
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote) nostr.setSigner(remote)
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
@@ -215,15 +239,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)
} }
} }
} }
@@ -256,7 +294,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
} }
} }
@@ -325,7 +363,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}")
} }
@@ -358,18 +396,16 @@ class NostrViewModel(
nostr.setSigner(keys) nostr.setSigner(keys)
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
} else if (secret.startsWith("bunker://")) { } else if (secret.startsWith("bunker://")) {
try { try {
val appKeys = getOrInitAppKeys() val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret) val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null)
nostr.setSigner(remote) nostr.setSigner(remote)
secretStore.set("user_signer", secret) secretStore.set("user_signer", secret)
// Set an empty secret state _signerRequired.value = false
_emptySecret.value = false
} catch (e: Exception) { } catch (e: Exception) {
showError("Error: ${e.message}") showError("Error: ${e.message}")
} }
@@ -411,14 +447,27 @@ class NostrViewModel(
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)
// Update the chat rooms state
_chatRooms.update { currentRooms -> _chatRooms.update { currentRooms ->
currentRooms + room currentRooms + room
} }
@@ -477,6 +526,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)
@@ -508,13 +560,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 ->
if (room.id == roomId) { currentRooms.map { room ->
room.copy(lastMessage = newMessage.content(), createdAt = newMessage.createdAt()) if (room.id == roomId) {
} else { room.copy(
room lastMessage = newMessage.content(),
} createdAt = newMessage.createdAt()
}.toSet() )
} else {
room
}
}.sortedDescending().toSet()
}
} }
suspend fun searchByAddress(query: String): PublicKey? { suspend fun searchByAddress(query: String): PublicKey? {

View File

@@ -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