chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
11 changed files with 571 additions and 31 deletions
Showing only changes of commit 6b448a56f8 - Show all commits

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M120,800L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120Q713,120 736.5,143.5Q760,167 760,200L760,403Q750,401 740,400.5Q730,400 720,400Q710,400 700,400.5Q690,401 680,403L680,200Q680,200 680,200Q680,200 680,200L200,200Q200,200 200,200Q200,200 200,200L200,600L483,600Q481,610 480.5,620Q480,630 480,640Q480,650 480.5,660Q481,670 483,680L240,680L120,800ZM280,360L600,360L600,280L280,280L280,360ZM280,520L480,520L480,440L280,440L280,520ZM680,800L680,680L560,680L560,600L680,600L680,480L760,480L760,600L880,600L880,680L760,680L760,800L680,800ZM200,600L200,600L200,200Q200,200 200,200Q200,200 200,200L200,200Q200,200 200,200Q200,200 200,200L200,403Q200,453 200,501.5Q200,550 200,600Z" />
</vector>

View File

@@ -14,10 +14,10 @@ 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.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
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
@@ -26,8 +26,10 @@ 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
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.ScanScreen
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> { val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
error("No NostrViewModel provided") error("No NostrViewModel provided")
@@ -37,18 +39,26 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
error("No SnackbarHostState provided") error("No SnackbarHostState provided")
} }
val LocalNavController = staticCompositionLocalOf<NavController> {
error("No NavController provided")
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun App(dbPath: String) { fun App(dbPath: String) {
val context = LocalContext.current val context = LocalContext.current
val navController = rememberNavController()
val darkMode = isSystemInDarkTheme()
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
// Initialize Nostr and SecretStore // Initialize Nostr and SecretStore
val nostr = remember { Nostr() } val nostr = remember { Nostr() }
val secretStore = remember { SecretStore(context) } val secretStore = remember { SecretStore(context) }
val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) }
// Dynamic color scheme // Enabled the dynamic color scheme
val darkMode = isSystemInDarkTheme()
val colorScheme = when { val colorScheme = when {
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)
@@ -58,13 +68,12 @@ fun App(dbPath: String) {
else -> expressiveLightColorScheme() else -> expressiveLightColorScheme()
} }
// Snackbar
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.initAndConnect(dbPath) viewModel.initAndConnect(dbPath)
viewModel.startNotificationHandler() viewModel.startNotificationHandler()
viewModel.getChatRooms() viewModel.getChatRooms()
// Collect error events from the ViewModel
viewModel.errorEvents.collect { message -> viewModel.errorEvents.collect { message ->
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
@@ -76,9 +85,8 @@ fun App(dbPath: String) {
CompositionLocalProvider( CompositionLocalProvider(
LocalNostrViewModel provides viewModel, LocalNostrViewModel provides viewModel,
LocalSnackbarHostState provides snackbarHostState, LocalSnackbarHostState provides snackbarHostState,
LocalNavController provides navController,
) { ) {
rememberCoroutineScope()
val navController = rememberNavController()
val emptySecret by viewModel.emptySecret.collectAsState(initial = null) val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
LaunchedEffect(emptySecret) { LaunchedEffect(emptySecret) {
@@ -136,7 +144,8 @@ fun App(dbPath: String) {
} }
composable<Screen.Home> { backStackEntry -> composable<Screen.Home> { backStackEntry ->
HomeScreen( HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
onNewChat = { navController.navigate(Screen.NewChat) }
) )
} }
composable<Screen.Chat> { backStackEntry -> composable<Screen.Chat> { backStackEntry ->
@@ -146,6 +155,16 @@ fun App(dbPath: String) {
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable<Screen.NewChat> { backStackEntry ->
NewChatScreen(
onBack = { navController.popBackStack() },
)
}
composable<Screen.Scan> { backStackEntry ->
ScanScreen(
onBack = { navController.popBackStack() },
)
}
} }
} }
} }

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 object NewChat : Screen
@Serializable @Serializable
data object Onboarding : Screen data object Onboarding : Screen
@@ -17,4 +20,7 @@ sealed interface Screen {
@Serializable @Serializable
data object NewIdentity : Screen data object NewIdentity : Screen
@Serializable
data object Scan : Screen
} }

View File

@@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -24,17 +26,23 @@ import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -47,6 +55,8 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.platform.toClipEntry
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.ic_new_chat
import coop.composeapp.generated.resources.ic_scanner
import coop.composeapp.generated.resources.ic_search import coop.composeapp.generated.resources.ic_search
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@@ -61,7 +71,10 @@ import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen(onOpenChat: (Long) -> Unit) { fun HomeScreen(
onOpenChat: (Long) -> Unit,
onNewChat: () -> Unit,
) {
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
@@ -74,6 +87,8 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val listState = rememberLazyListState()
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@@ -98,6 +113,13 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
contentDescription = "Search" contentDescription = "Search"
) )
} }
// QR Scanner
IconButton(onClick = { /* TODO: Open search */ }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
// User // User
IconButton(onClick = { showBottomSheet = true }) { IconButton(onClick = { showBottomSheet = true }) {
Avatar( Avatar(
@@ -109,6 +131,32 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
} }
) )
}, },
floatingActionButton = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
tooltip = {
if (!expandedFab) {
PlainTooltip { Text("New Chat") }
}
},
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = onNewChat,
expanded = expandedFab,
icon = {
Icon(
painter = painterResource(Res.drawable.ic_new_chat),
contentDescription = "New Chat"
)
},
text = { Text("New Chat") },
)
}
},
content = { innerPadding -> content = { innerPadding ->
Surface( Surface(
modifier = Modifier modifier = Modifier
@@ -137,6 +185,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
} }
} else { } else {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items(chatRooms.toList(), key = { it.id }) { room -> items(chatRooms.toList(), key = { it.id }) { room ->

View File

@@ -0,0 +1,300 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_close_small
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun NewChatScreen(
onBack: () -> Unit,
) {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") }
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle?.getStateFlow<String?>("qr_result", null)?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(query) {
if (query.length >= 3) {
delay(500) // 500ms debounce
// TODO: Implement search
}
}
LaunchedEffect(qrResult) {
qrResult?.let {
println("QR result: $it")
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = { Text("New Chat") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
}
)
},
content = { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface,
) {
FlowRow(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "To:",
modifier = Modifier.align(Alignment.Top),
style = MaterialTheme.typography.labelMediumEmphasized,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
selectedReceivers.forEach { receiver ->
ReceiverChip(
pubkey = receiver,
onRemove = { selectedReceivers.remove(receiver) }
)
}
BasicTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier
.widthIn(min = 50.dp)
.align(Alignment.CenterVertically),
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty() && selectedReceivers.isEmpty()) {
Text(
"Type a npub or address",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
}
}
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
// TODO: add result list
ContactList(
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
}
)
Spacer(modifier = Modifier.size(16.dp))
}
}
}
)
}
@Composable
fun ReceiverChip(
pubkey: PublicKey,
onRemove: () -> Unit
) {
val viewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
InputChip(
selected = true,
onClick = onRemove,
label = { Text(displayName) },
avatar = {
Avatar(
picture = picture,
description = displayName,
size = 24.dp
)
},
trailingIcon = {
Icon(
painter = painterResource(Res.drawable.ic_close_small),
contentDescription = "Close"
)
}
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactList(
selectedReceivers: SnapshotStateList<PublicKey>,
onContactClick: (PublicKey) -> Unit
) {
val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
Text(
text = "Contacts",
style = MaterialTheme.typography.titleLargeEmphasized,
)
Spacer(modifier = Modifier.size(8.dp))
contactList.forEachIndexed { index, item ->
ContactListItem(
pubkey = item,
index = index,
total = contactList.size,
isSelected = selectedReceivers.contains(item),
onClick = { onContactClick(item) },
onLongClick = {
if (!selectedReceivers.contains(item)) {
selectedReceivers.add(item)
}
}
)
}
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactListItem(
pubkey: PublicKey,
index: Int,
total: Int = 0,
isSelected: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
val viewModel = LocalNostrViewModel.current
val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) }
val metadata by metadataFlow.collectAsState(initial = null)
val profile = metadata?.asRecord()
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
SegmentedListItem(
selected = isSelected,
onClick = onClick,
onLongClick = onLongClick,
shapes = ListItemDefaults.segmentedShapes(
index = index,
count = total
),
leadingContent = {
Avatar(
picture = picture,
description = displayName,
size = 36.dp
)
},
supportingContent = { Text(text = pubkey.short()) },
content = {
Text(
text = displayName,
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
)
}

View File

@@ -0,0 +1,49 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalNavController
@Composable
fun ScanScreen(
onBack: () -> Unit
) {
val navController = LocalNavController.current
val onResult: (String) -> Unit = { result ->
navController.previousBackStackEntry
?.savedStateHandle
?.set("qr_result", result)
navController.popBackStack()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Scan QR") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
painter = painterResource(Res.drawable.ic_arrow_back),
contentDescription = "Back"
)
}
},
)
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
Text("Scan QR")
}
}
}

View File

@@ -1,7 +1,10 @@
package su.reya.coop package su.reya.coop
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -24,6 +27,8 @@ import rust.nostr.sdk.KindStandard
import rust.nostr.sdk.LogLevel import rust.nostr.sdk.LogLevel
import rust.nostr.sdk.Metadata import rust.nostr.sdk.Metadata
import rust.nostr.sdk.MetadataRecord import rust.nostr.sdk.MetadataRecord
import rust.nostr.sdk.Nip05Address
import rust.nostr.sdk.Nip05Profile
import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrDatabase
import rust.nostr.sdk.NostrGossip import rust.nostr.sdk.NostrGossip
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
@@ -56,8 +61,6 @@ class Nostr {
private set private set
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap() var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
private set private set
var contactList: List<PublicKey> = emptyList()
private set
suspend fun init(dbPath: String) { suspend fun init(dbPath: String) {
try { try {
@@ -87,6 +90,12 @@ class Nostr {
client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
// Add search relay
client?.addRelay(
url = RelayUrl.parse("wss://antiprimal.net"),
capabilities = RelayCapabilities.read()
)
// Indexer relay for NIP-65 discovery // Indexer relay for NIP-65 discovery
client?.addRelay( client?.addRelay(
url = RelayUrl.parse("wss://indexer.coracle.social"), url = RelayUrl.parse("wss://indexer.coracle.social"),
@@ -107,7 +116,6 @@ class Nostr {
suspend fun exit() { suspend fun exit() {
signer.switch(Keys.generate()) signer.switch(Keys.generate())
deviceSigner = null deviceSigner = null
contactList = emptyList()
} }
suspend fun setSigner(keys: AsyncNostrSigner) { suspend fun setSigner(keys: AsyncNostrSigner) {
@@ -184,6 +192,7 @@ class Nostr {
suspend fun handleNotifications( suspend fun handleNotifications(
onMetadataUpdate: (PublicKey, Metadata) -> Unit, onMetadataUpdate: (PublicKey, Metadata) -> Unit,
onContactListUpdate: (List<PublicKey>) -> Unit,
onNewMessage: (UnsignedEvent) -> Unit, onNewMessage: (UnsignedEvent) -> Unit,
onEose: () -> Unit, onEose: () -> Unit,
) = coroutineScope { ) = coroutineScope {
@@ -217,6 +226,12 @@ class Nostr {
} }
} }
if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) {
if (isSignedByUser(event = event)) {
onContactListUpdate(event.tags().publicKeys())
}
}
if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) {
if (isSignedByUser(event = event)) { if (isSignedByUser(event = event)) {
getUserMessages(msgRelayList = event) getUserMessages(msgRelayList = event)
@@ -457,7 +472,7 @@ class Nostr {
) )
) )
client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) client?.subscribe(target = target, closeOn = opts)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e)
} }
@@ -494,13 +509,10 @@ class Nostr {
Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList());
// Check if the user is interacting with the room's members // Check if the user is interacting with the room's members
val isInteracting = client?.database()?.query(filter)?.isEmpty() == false; val isOngoing = client?.database()?.query(filter)?.isEmpty() == false;
// Check if the room's members are in the contact list
val isContact = contactList.containsAll(room.members)
// Set the room kind based on interaction status // Set the room kind based on interaction status
if (isInteracting || isContact) { if (isOngoing) {
room.setKind(RoomKind.Ongoing) room.setKind(RoomKind.Ongoing)
} }
@@ -614,4 +626,54 @@ class Nostr {
throw IllegalStateException("Failed to send message: ${e.message}", e) throw IllegalStateException("Failed to send message: ${e.message}", e)
} }
} }
suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile {
try {
val response: HttpResponse = client.get(address.url())
val bodyString: String = response.body()
return Nip05Profile.fromJson(address, bodyString)
} catch (e: Exception) {
throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e)
}
}
suspend fun searchByAddress(query: String): List<PublicKey> {
try {
val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address)
return listOf(profile.publicKey())
} catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e)
}
}
suspend fun searchByNostr(query: String) {
try {
val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u)
val target =
ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter)))
val stream = client?.streamEvents(
target = target,
id = "search",
timeout = Duration.parse("4s"),
policy = ReqExitPolicy.ExitOnEose
)
// Collect the results
val results = mutableListOf<PublicKey>()
// Keep searching until the stream is closed or timeout
stream?.next()?.let { event ->
if (event.event != null) {
results.add(event.event!!.author())
}
}
} catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e)
}
}
} }

View File

@@ -17,12 +17,14 @@ 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.EventBuilder
import rust.nostr.sdk.EventId import rust.nostr.sdk.EventId
import rust.nostr.sdk.Keys import rust.nostr.sdk.Keys
import rust.nostr.sdk.Metadata import rust.nostr.sdk.Metadata
import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnect
import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey import rust.nostr.sdk.PublicKey
import rust.nostr.sdk.Tag
import rust.nostr.sdk.UnsignedEvent 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
@@ -42,6 +44,9 @@ class NostrViewModel(
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet()) private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow() val chatRooms = _chatRooms.asStateFlow()
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
val contactList = _contactList.asStateFlow()
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100) private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow() val newEvents = _newEvents.asSharedFlow()
@@ -56,6 +61,16 @@ class NostrViewModel(
startMetadataBatchProcessor() startMetadataBatchProcessor()
} }
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
private fun showError(message: String) { private fun showError(message: String) {
viewModelScope.launch { viewModelScope.launch {
_errorEvents.send(message) _errorEvents.send(message)
@@ -133,6 +148,9 @@ class NostrViewModel(
onMetadataUpdate = { pubkey, metadata -> onMetadataUpdate = { pubkey, metadata ->
updateMetadata(pubkey, metadata) updateMetadata(pubkey, metadata)
}, },
onContactListUpdate = { contactList ->
_contactList.value = contactList.toSet()
},
onEose = { onEose = {
getChatRooms() getChatRooms()
}, },
@@ -287,6 +305,23 @@ class NostrViewModel(
} }
} }
fun createChatRoom(to: List<PublicKey>): Long {
if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in")
if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required")
// Construct the rumor event
val rumor = EventBuilder
.privateMsgRumor(to.first(), "")
.tags(to.map { Tag.publicKey(it) })
.build(nostr.signer.currentUser!!)
// Create a room from the rumor event
val room = Room.new(rumor, nostr.signer.currentUser!!)
_chatRooms.value += room
return room.id
}
fun getChatRoom(id: Long): Room { fun getChatRoom(id: Long): Room {
return chatRooms.value.firstOrNull { it.id == id } return chatRooms.value.firstOrNull { it.id == id }
?: throw IllegalArgumentException("Room not found") ?: throw IllegalArgumentException("Room not found")
@@ -345,16 +380,6 @@ class NostrViewModel(
} }
} }
} }
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
} }
fun PublicKey.short(): String { fun PublicKey.short(): String {

View File

@@ -111,7 +111,7 @@ fun Timestamp.ago(): String {
val duration = now - inputInstant val duration = now - inputInstant
return when { return when {
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "now" duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m" duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h" duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d" duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"