chore: merge the develop branch into master #1
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -14,10 +14,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
@@ -26,8 +26,10 @@ import su.reya.coop.coop.storage.SecretStore
|
||||
import su.reya.coop.screens.ChatScreen
|
||||
import su.reya.coop.screens.HomeScreen
|
||||
import su.reya.coop.screens.ImportScreen
|
||||
import su.reya.coop.screens.NewChatScreen
|
||||
import su.reya.coop.screens.NewIdentityScreen
|
||||
import su.reya.coop.screens.OnboardingScreen
|
||||
import su.reya.coop.screens.ScanScreen
|
||||
|
||||
val LocalNostrViewModel = staticCompositionLocalOf<NostrViewModel> {
|
||||
error("No NostrViewModel provided")
|
||||
@@ -37,18 +39,26 @@ val LocalSnackbarHostState = staticCompositionLocalOf<SnackbarHostState> {
|
||||
error("No SnackbarHostState provided")
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
||||
error("No NavController provided")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun App(dbPath: String) {
|
||||
val context = LocalContext.current
|
||||
val navController = rememberNavController()
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
// Initialize Nostr and SecretStore
|
||||
val nostr = remember { Nostr() }
|
||||
val secretStore = remember { SecretStore(context) }
|
||||
val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) }
|
||||
|
||||
// Dynamic color scheme
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
|
||||
// Enabled the dynamic color scheme
|
||||
val colorScheme = when {
|
||||
android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> {
|
||||
if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
@@ -58,13 +68,12 @@ fun App(dbPath: String) {
|
||||
else -> expressiveLightColorScheme()
|
||||
}
|
||||
|
||||
// Snackbar
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.initAndConnect(dbPath)
|
||||
viewModel.startNotificationHandler()
|
||||
viewModel.getChatRooms()
|
||||
|
||||
// Collect error events from the ViewModel
|
||||
viewModel.errorEvents.collect { message ->
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
@@ -76,9 +85,8 @@ fun App(dbPath: String) {
|
||||
CompositionLocalProvider(
|
||||
LocalNostrViewModel provides viewModel,
|
||||
LocalSnackbarHostState provides snackbarHostState,
|
||||
LocalNavController provides navController,
|
||||
) {
|
||||
rememberCoroutineScope()
|
||||
val navController = rememberNavController()
|
||||
val emptySecret by viewModel.emptySecret.collectAsState(initial = null)
|
||||
|
||||
LaunchedEffect(emptySecret) {
|
||||
@@ -136,7 +144,8 @@ fun App(dbPath: String) {
|
||||
}
|
||||
composable<Screen.Home> { backStackEntry ->
|
||||
HomeScreen(
|
||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }
|
||||
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) },
|
||||
onNewChat = { navController.navigate(Screen.NewChat) }
|
||||
)
|
||||
}
|
||||
composable<Screen.Chat> { backStackEntry ->
|
||||
@@ -146,6 +155,16 @@ fun App(dbPath: String) {
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.NewChat> { backStackEntry ->
|
||||
NewChatScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable<Screen.Scan> { backStackEntry ->
|
||||
ScanScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ sealed interface Screen {
|
||||
@Serializable
|
||||
data class Chat(val id: Long) : Screen
|
||||
|
||||
@Serializable
|
||||
data object NewChat : Screen
|
||||
|
||||
@Serializable
|
||||
data object Onboarding : Screen
|
||||
|
||||
@@ -17,4 +20,7 @@ sealed interface Screen {
|
||||
|
||||
@Serializable
|
||||
data object NewIdentity : Screen
|
||||
|
||||
@Serializable
|
||||
data object Scan : Screen
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -24,17 +26,23 @@ import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.material3.toShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,6 +55,8 @@ import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.toClipEntry
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coop.composeapp.generated.resources.Res
|
||||
import coop.composeapp.generated.resources.ic_new_chat
|
||||
import coop.composeapp.generated.resources.ic_scanner
|
||||
import coop.composeapp.generated.resources.ic_search
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
@@ -61,7 +71,10 @@ import su.reya.coop.short
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
||||
fun HomeScreen(
|
||||
onOpenChat: (Long) -> Unit,
|
||||
onNewChat: () -> Unit,
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val snackbarHostState = LocalSnackbarHostState.current
|
||||
val viewModel = LocalNostrViewModel.current
|
||||
@@ -74,6 +87,8 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
||||
val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList())
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val listState = rememberLazyListState()
|
||||
val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } }
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
@@ -98,6 +113,13 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
||||
contentDescription = "Search"
|
||||
)
|
||||
}
|
||||
// QR Scanner
|
||||
IconButton(onClick = { /* TODO: Open search */ }) {
|
||||
Icon(
|
||||
painter = painterResource(Res.drawable.ic_scanner),
|
||||
contentDescription = "Scanner"
|
||||
)
|
||||
}
|
||||
// User
|
||||
IconButton(onClick = { showBottomSheet = true }) {
|
||||
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 ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -137,6 +185,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) {
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(chatRooms.toList(), key = { it.id }) { room ->
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package su.reya.coop
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -24,6 +27,8 @@ import rust.nostr.sdk.KindStandard
|
||||
import rust.nostr.sdk.LogLevel
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.MetadataRecord
|
||||
import rust.nostr.sdk.Nip05Address
|
||||
import rust.nostr.sdk.Nip05Profile
|
||||
import rust.nostr.sdk.NostrDatabase
|
||||
import rust.nostr.sdk.NostrGossip
|
||||
import rust.nostr.sdk.PublicKey
|
||||
@@ -56,8 +61,6 @@ class Nostr {
|
||||
private set
|
||||
var msgRelayList: Map<PublicKey, List<RelayUrl>> = emptyMap()
|
||||
private set
|
||||
var contactList: List<PublicKey> = emptyList()
|
||||
private set
|
||||
|
||||
suspend fun init(dbPath: String) {
|
||||
try {
|
||||
@@ -87,6 +90,12 @@ class Nostr {
|
||||
client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
|
||||
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
|
||||
client?.addRelay(
|
||||
url = RelayUrl.parse("wss://indexer.coracle.social"),
|
||||
@@ -107,7 +116,6 @@ class Nostr {
|
||||
suspend fun exit() {
|
||||
signer.switch(Keys.generate())
|
||||
deviceSigner = null
|
||||
contactList = emptyList()
|
||||
}
|
||||
|
||||
suspend fun setSigner(keys: AsyncNostrSigner) {
|
||||
@@ -184,6 +192,7 @@ class Nostr {
|
||||
|
||||
suspend fun handleNotifications(
|
||||
onMetadataUpdate: (PublicKey, Metadata) -> Unit,
|
||||
onContactListUpdate: (List<PublicKey>) -> Unit,
|
||||
onNewMessage: (UnsignedEvent) -> Unit,
|
||||
onEose: () -> Unit,
|
||||
) = 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 (isSignedByUser(event = 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) {
|
||||
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());
|
||||
|
||||
// Check if the user is interacting with the room's members
|
||||
val isInteracting = client?.database()?.query(filter)?.isEmpty() == false;
|
||||
|
||||
// Check if the room's members are in the contact list
|
||||
val isContact = contactList.containsAll(room.members)
|
||||
val isOngoing = client?.database()?.query(filter)?.isEmpty() == false;
|
||||
|
||||
// Set the room kind based on interaction status
|
||||
if (isInteracting || isContact) {
|
||||
if (isOngoing) {
|
||||
room.setKind(RoomKind.Ongoing)
|
||||
}
|
||||
|
||||
@@ -614,4 +626,54 @@ class Nostr {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,14 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import rust.nostr.sdk.EventBuilder
|
||||
import rust.nostr.sdk.EventId
|
||||
import rust.nostr.sdk.Keys
|
||||
import rust.nostr.sdk.Metadata
|
||||
import rust.nostr.sdk.NostrConnect
|
||||
import rust.nostr.sdk.NostrConnectUri
|
||||
import rust.nostr.sdk.PublicKey
|
||||
import rust.nostr.sdk.Tag
|
||||
import rust.nostr.sdk.UnsignedEvent
|
||||
import su.reya.coop.blossom.BlossomClient
|
||||
import su.reya.coop.storage.SecretStorage
|
||||
@@ -42,6 +44,9 @@ class NostrViewModel(
|
||||
private val _chatRooms = MutableStateFlow<Set<Room>>(emptySet())
|
||||
val chatRooms = _chatRooms.asStateFlow()
|
||||
|
||||
private val _contactList = MutableStateFlow<Set<PublicKey>>(emptySet())
|
||||
val contactList = _contactList.asStateFlow()
|
||||
|
||||
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
|
||||
val newEvents = _newEvents.asSharedFlow()
|
||||
|
||||
@@ -56,6 +61,16 @@ class NostrViewModel(
|
||||
startMetadataBatchProcessor()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
// Ensure all relays are disconnect
|
||||
viewModelScope.launch {
|
||||
withContext(NonCancellable) {
|
||||
nostr.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
viewModelScope.launch {
|
||||
_errorEvents.send(message)
|
||||
@@ -133,6 +148,9 @@ class NostrViewModel(
|
||||
onMetadataUpdate = { pubkey, metadata ->
|
||||
updateMetadata(pubkey, metadata)
|
||||
},
|
||||
onContactListUpdate = { contactList ->
|
||||
_contactList.value = contactList.toSet()
|
||||
},
|
||||
onEose = {
|
||||
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 {
|
||||
return chatRooms.value.firstOrNull { it.id == id }
|
||||
?: 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 {
|
||||
|
||||
@@ -111,7 +111,7 @@ fun Timestamp.ago(): String {
|
||||
val duration = now - inputInstant
|
||||
|
||||
return when {
|
||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "now"
|
||||
duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now"
|
||||
duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m"
|
||||
duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h"
|
||||
duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"
|
||||
|
||||
Reference in New Issue
Block a user