new chat screen
This commit is contained in:
@@ -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.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() },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user