Chat room creation

This commit is contained in:
Kgothatso Ngako
2026-06-18 17:31:09 +03:00
parent 60bb70c641
commit 55198de44e
14 changed files with 414 additions and 118 deletions

View File

@@ -28,36 +28,36 @@ abstract class NostrNip17Dao(
val logger = Logger.withTag(TAG)
@Transaction
open suspend fun getOrCreateChatRoom(activeUserPublicKey: HexKey, publicKey: String, relayHint: String?, defaultSubject: String? = null): at.torch.compose.database.model.intermdiate.LocalChatRoom? {
logger.d("getOrCreateChatRoom: $publicKey")
open suspend fun getOrCreateChatRoom(chatRoomId: String, activeUserPublicKey: HexKey, relayHint: String?, defaultSubject: String? = null, mlsGroupState: String? = null): LocalChatRoom? {
logger.d("getOrCreateChatRoom: $chatRoomId")
val hexKeys = setOf(
activeUserPublicKey,
publicKey
// publicKey
)
val chatRoomId = _root_ide_package_.at.torch.compose.database.model.ChatRoom.Companion.deriveChatRoomId(hexKeys)
logger.d("chatRoomId: $chatRoomId")
val localChatRoom = database.chatRoomDao().findChatRoomById(chatRoomId)
if (localChatRoom != null) {
// TODO: might want to update the mlsGroupState?
return localChatRoom
} else {
val profiles = database.profileDao().getProfileByPublicKeys(hexKeys.toList())
if (profiles.isNotEmpty()) {
// Create new chatRoom
val chatRoom = _root_ide_package_.at.torch.compose.database.model.ChatRoom(
val chatRoom = ChatRoom(
id = chatRoomId,
userPublicKey = activeUserPublicKey,
subject = defaultSubject,
mlsGroupState = mlsGroupState
)
database.chatRoomDao().upsert(chatRoom)
database.participantDao().upsert(
profiles.map {
_root_ide_package_.at.torch.compose.database.model.Participant(
Participant(
participantPublicKey = it.publicKey,
chatRoomId = chatRoomId,
relayHint = relayHint
@@ -65,7 +65,7 @@ abstract class NostrNip17Dao(
}
)
return _root_ide_package_.at.torch.compose.database.model.intermdiate.LocalChatRoom(
return LocalChatRoom(
chatRoom = chatRoom
)
}
@@ -81,7 +81,7 @@ abstract class NostrNip17Dao(
giftWrapEvent: GiftWrapEvent,
receiverPublicKey: PTag
) {
val nostrEvent = _root_ide_package_.at.torch.compose.database.model.NostrEvent(
val nostrEvent = NostrEvent(
id = giftWrapEvent.id,
content = giftWrapEvent.content,
createdAt = Instant.fromEpochSeconds(giftWrapEvent.createdAt),
@@ -99,7 +99,7 @@ abstract class NostrNip17Dao(
chatMessage?.let {
database.chatMessageNostrEventRelationDao().upsert(
_root_ide_package_.at.torch.compose.database.model.ChatMessageNostrEventRelation(
ChatMessageNostrEventRelation(
chatMessageId = it.id,
nostrEventId = nostrEvent.id
)
@@ -112,7 +112,7 @@ abstract class NostrNip17Dao(
authors = arrayOf(
receiverPublicKey.pubKey
),
since = _root_ide_package_.at.torch.compose.database.GENESIS_AT,
since = GENESIS_AT,
limit = 5
)
logger.d("Found messageRelayLists: $chatMessageRelayListEvents")
@@ -134,7 +134,7 @@ abstract class NostrNip17Dao(
logger.d("Submit BroadcastNostrEventRequest to $relayUrl")
val broadcastNostrEventRequestIds = database.broadcastNostrEventRequestDao().insert(
listOf(
_root_ide_package_.at.torch.compose.database.model.BroadcastNostrEventRequest(
BroadcastNostrEventRequest(
nostrEventId = nostrEvent.id,
relayURL = relayUrl.url
)
@@ -144,7 +144,7 @@ abstract class NostrNip17Dao(
chatMessage?.let {
broadcastNostrEventRequestIds.forEach {
database.chatMessageBroadcastNostrEventRequestRelationDao().upsert(
_root_ide_package_.at.torch.compose.database.model.ChatMessageBroadcastNostrEventRequestRelation(
ChatMessageBroadcastNostrEventRequestRelation(
chatMessageId = chatMessage.id,
broadcastNostrEventRequestId = it
)
@@ -159,7 +159,7 @@ abstract class NostrNip17Dao(
database.giftWrapMessageDao().upsert(
_root_ide_package_.at.torch.compose.database.model.GiftWrapMessage(
GiftWrapMessage(
id = giftWrapEvent.id,
nostrEventId = nostrEvent.id,
content = giftWrapEvent.content,
@@ -173,7 +173,7 @@ abstract class NostrNip17Dao(
)
database.giftWrapSealDao().upsert(
_root_ide_package_.at.torch.compose.database.model.GiftWrapSeal(
GiftWrapSeal(
id = sealedRumorEvent.id,
publicKey = sealedRumorEvent.pubKey,
content = sealedRumorEvent.content,

View File

@@ -52,6 +52,8 @@ data class ChatRoom(
*/
val subject: String?,
val mlsGroupState: String?, // TODO: Can this be nullable???
/**
* Derived from kind14/15 tags
* [

View File

@@ -4,6 +4,7 @@ import at.torch.compose.database.GENESIS_AT
import at.torch.compose.database.model.ChatMessage
import at.torch.compose.database.model.GiftWrapPayload
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
@@ -45,16 +46,18 @@ class DatabaseChatRepository(
override suspend fun getOrCreateChatRoom(
chatRoomId: String,
activeUserPublicKey: HexKey,
publicKey: String,
relayHint: String?,
defaultSubject: String?
defaultSubject: String?,
mlsGroupState: String?
): at.torch.compose.database.model.intermdiate.LocalChatRoom? = try {
return database.nostrNip17Dao().getOrCreateChatRoom(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
publicKey = publicKey,
relayHint = relayHint,
defaultSubject = defaultSubject
defaultSubject = defaultSubject,
mlsGroupState = mlsGroupState
)
} catch (e: Throwable) {
logger.e("Error getting or creating chat", e)

View File

@@ -8,6 +8,7 @@ import at.torch.compose.database.model.UnsignedNostrEvent
import at.torch.compose.nostr.Relays
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.KEY_PACKAGE_LIFETIME_SECONDS
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
@@ -161,10 +162,11 @@ class DatabaseNostrRepository(
)
unsignedNostrEvents.add(
_root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent(
UnsignedNostrEvent(
pubKey = publicKey,
kind = AdvertisedRelayListEvent.KIND,
tags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultNIP65List.map { it.toTagArray() }
kind = KeyPackageRelayListEvent.KIND,
tags = Relays.DefaultDMRelayList.map { arrayOf("relay", it.url) }
.plusElement(AltTag.assemble(KeyPackageRelayListEvent.ALT_DESCRIPTION))
.toTypedArray(),
content = ""
)
@@ -173,10 +175,10 @@ class DatabaseNostrRepository(
unsignedNostrEvents.add(
_root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent(
UnsignedNostrEvent(
pubKey = publicKey,
kind = SearchRelayListEvent.KIND,
tags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultSearchRelayList.map {
tags = Relays.DefaultSearchRelayList.map {
RelayTag.assemble(
it
)
@@ -189,35 +191,6 @@ class DatabaseNostrRepository(
)
)
unsignedNostrEvents.add(
_root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent(
pubKey = publicKey,
kind = IndexerRelayListEvent.KIND,
tags = arrayOf(AltTag.assemble(IndexerRelayListEvent.ALT)),
privateTags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultIndexerRelayList.map {
RelayTag.assemble(
it
)
}.toTypedArray(),
content = ""
)
)
unsignedNostrEvents.add(
_root_ide_package_.at.torch.compose.database.model.UnsignedNostrEvent(
pubKey = publicKey,
kind = ChannelListEvent.KIND,
tags = arrayOf(
AltTag.assemble(
ChannelListEvent.ALT
)
),
privateTags = _root_ide_package_.at.torch.compose.nostr.Relays.DefaultChannels.map { it.toTagArray() }
.toTypedArray(),
content = ""
)
)
unsignedNostrEvents.add(
UnsignedNostrEvent(
pubKey = publicKey,

View File

@@ -20,9 +20,11 @@ interface ChatRepository {
suspend fun observeChatMessageListByChatRoomId(chatRoomId: String): Flow<List<at.torch.compose.database.model.intermdiate.LocalChatMessage>>
suspend fun getOrCreateChatRoom(
chatRoomId: String,
activeUserPublicKey: HexKey,
publicKey: String, relayHint: String?,
defaultSubject: String? = null
relayHint: String?,
defaultSubject: String?,
mlsGroupState: String?
): at.torch.compose.database.model.intermdiate.LocalChatRoom?
suspend fun getChatMessageRelayForPublicKey(publicKey: HexKey): ChatMessageRelayListEvent?
@@ -63,10 +65,11 @@ interface ChatRepository {
}
override suspend fun getOrCreateChatRoom(
chatRoomId: String,
activeUserPublicKey: HexKey,
publicKey: String,
relayHint: String?,
defaultSubject: String?
defaultSubject: String?,
mlsGroupState: String?
): at.torch.compose.database.model.intermdiate.LocalChatRoom? {
return null
}

View File

@@ -0,0 +1,187 @@
package at.torch.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Badge
import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import at.torch.compose.repository.ChatRepository
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.view.model.ChatRoomCreationViewModel
import at.torch.compose.ui.view.state.form.TextField
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.phoenix.data.ActiveWallet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@Composable
fun ChatRoomCreationScreen(
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
onNavigateToChatRoom: (Route) -> Unit
) {
val chatRoomCreationViewModel: ChatRoomCreationViewModel = viewModel (
factory = ChatRoomCreationViewModel.factory(
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository
)
)
Scaffold { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth()
) {
Column(
modifier = Modifier.fillMaxWidth().padding(10.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
TextField(
modifier = Modifier.fillMaxWidth(),
state = chatRoomCreationViewModel.chatRoomCreationFormState.nameField.textFieldState,
isError = chatRoomCreationViewModel.chatRoomCreationFormState.nameField.errorMessage.value != null,
supportingText = chatRoomCreationViewModel.chatRoomCreationFormState.nameField.errorMessage.value?.let {
{
Text(
text = it,
modifier = Modifier.padding(
bottom = 8.dp
)
)
}
},
lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(
text = "Name (eg. Alan Turin)",
maxLines = 1,
)
},
placeholder = {
Text(
text = "Enter the name you want to use for your profile",
maxLines = 1,
)
},
leadingIcon = {
Icon(
Icons.Default.Badge,
contentDescription = "Name"
)
}
)
Text(
text = "This will be the display name for your profile and also important for search.",
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center
)
TextField(
modifier = Modifier.fillMaxWidth(),
state = chatRoomCreationViewModel.chatRoomCreationFormState.descriptionField.textFieldState,
isError = chatRoomCreationViewModel.chatRoomCreationFormState.descriptionField.errorMessage.value != null,
supportingText = chatRoomCreationViewModel.chatRoomCreationFormState.descriptionField.errorMessage.value?.let {
{
Text(
text = it,
modifier = Modifier.padding(
bottom = 8.dp
)
)
}
},
lineLimits = TextFieldLineLimits.MultiLine(4),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(
text = "Introduce yourself",
maxLines = 1,
)
},
placeholder = {
Text(
text = "What should people know about you?",
maxLines = 1,
)
},
leadingIcon = {
Icon(
Icons.Default.Description,
contentDescription = "Name"
)
}
)
Text(
text = "This will be shown when people open your profile.",
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center
)
Button(
onClick = {
chatRoomCreationViewModel.createChatRoom(
onNavigateToChatRoom = {
}
)
}
) {
Text(
"Create chat"
)
}
}
}
}
}
@Preview
@Composable
private fun LoadingScreenPreview() {
_root_ide_package_.at.torch.compose.ui.theme.TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
ChatRoomCreationScreen(
activeWalletStateFlow = MutableStateFlow(null),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToChatRoom = {}
)
}
}
}

View File

@@ -276,7 +276,8 @@ private fun ChatRoomDetailScreenPreview() {
id = "",
userPublicKey = "",
subject = "Message title",
initialGiftWrapPayloadId = 0
initialGiftWrapPayloadId = 0,
mlsGroupState = null
),
)
),

View File

@@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import at.torch.compose.ui.view.state.CreateProfileUIState
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -358,7 +359,7 @@ fun CreateAccountScreenPreview() {
) {
CreateProfileScreen(
initialCreateProfileUIState =
// CreateProfileUIState.InputPrompt,
CreateProfileUIState.InputPrompt,
// CreateProfileUIState.Error,
// CreateProfileUIState.ConfirmInput(
// name = "Alan Turing",
@@ -385,14 +386,14 @@ fun CreateAccountScreenPreview() {
// name = "Alan Turing",
// bio = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
// ),
_root_ide_package_.at.torch.compose.ui.view.state.CreateProfileUIState.ProfileReady(
profile = _root_ide_package_.at.torch.compose.database.model.Profile(
publicKey = "",
nostrEventId = "",
displayName = "Alan Turing",
about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
)
),
// _root_ide_package_.at.torch.compose.ui.view.state.CreateProfileUIState.ProfileReady(
// profile = _root_ide_package_.at.torch.compose.database.model.Profile(
// publicKey = "",
// nostrEventId = "",
// displayName = "Alan Turing",
// about = "Polyglot: Math... is my middle name, computer scientist. cryptographer, and gold hider."
// )
// ),
onNavigateToEndThis = {},
writeSeed = {},
nostrRepository = _root_ide_package_.at.torch.compose.repository.NostrRepository.Companion.NO_OP_NOSTR_REPOSITORY

View File

@@ -1,11 +1,5 @@
package at.torch.compose.ui.composable
import at.torch.compose.database.model.NostrEvent
import at.torch.compose.database.model.Profile
import at.torch.compose.database.model.intermdiate.LocalProfileWithFollowing
import at.torch.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -14,7 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -50,7 +44,7 @@ fun HomeScreen(
initialHomeScreenUIState: at.torch.compose.ui.view.state.HomeScreenUIState = _root_ide_package_.at.torch.compose.ui.view.state.HomeScreenUIState.Loading,
onNavigateToEvent: (at.torch.compose.ui.composable.navigation.routes.Route) -> Unit,
onNavigateToDirectMessageDetail: (at.torch.compose.ui.composable.navigation.routes.Route) -> Unit,
onNavigateToWriteNewNote: () -> Unit,
onNavigateToChatRoomCreation: () -> Unit,
onNavigateToSearch: () -> Unit,
nostrRepository: at.torch.compose.repository.NostrRepository,
chatRepository: at.torch.compose.repository.ChatRepository
@@ -136,15 +130,15 @@ fun HomeScreen(
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNavigateToWriteNewNote,
onClick = onNavigateToChatRoomCreation,
icon = {
Icon(
Icons.Default.Create,
contentDescription = "Write"
Icons.Default.Add,
contentDescription = "New Chat"
)
},
text = {
Text("Write")
Text("New Chat")
}
)
}
@@ -182,8 +176,8 @@ fun HomeScreen(
HorizontalPager(
state = homeScreenViewModel.pagerState,
) { pageIndex ->
when (val homeScreenTypeType = _root_ide_package_.at.torch.compose.ui.view.model.HomeScreenType.entries[pageIndex]) {
_root_ide_package_.at.torch.compose.ui.view.model.HomeScreenType.Messages -> {
when (val homeScreenTypeType = HomeScreenType.entries[pageIndex]) {
HomeScreenType.Messages -> {
val chatRoomListViewModel: at.torch.compose.ui.view.model.ChatRoomListViewModel = viewModel(
key = homeScreenTypeType.name,
factory = _root_ide_package_.at.torch.compose.ui.view.model.ChatRoomListViewModel.Companion.factory(
@@ -256,7 +250,7 @@ It has survived not only five centuries, but also the leap into electronic types
)
),
onNavigateToEvent = {},
onNavigateToWriteNewNote = {},
onNavigateToChatRoomCreation = {},
onNavigateToSearch = {},
onNavigateToDirectMessageDetail = {},
nostrRepository = _root_ide_package_.at.torch.compose.repository.NostrRepository.Companion.NO_OP_NOSTR_REPOSITORY,

View File

@@ -14,6 +14,9 @@ import androidx.compose.ui.graphics.Color
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.composable
import at.torch.compose.ui.composable.ChatRoomCreationScreen
import at.torch.compose.ui.composable.HomeScreen
import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.PhoenixGlobal
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -290,6 +293,15 @@ fun TorchNavHost(
)
}
composable<ChatRoomCreationRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChatRoomCreationRoute>()
ChatRoomCreationScreen(
activeUserPublicKey = route.activeUserPublicKey,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository
)
}
composable<WriteNewNoteRoute> { backStackEntry ->
val route = backStackEntry.toRoute<WriteNewNoteRoute>()
_root_ide_package_.at.torch.compose.ui.composable.WriteNewNoteScreen(
@@ -375,16 +387,16 @@ fun TorchNavHost(
}
composable<at.torch.compose.ui.composable.navigation.routes.FeedRoute> { backStackEntry ->
val route = backStackEntry.toRoute<at.torch.compose.ui.composable.navigation.routes.FeedRoute>()
_root_ide_package_.at.torch.compose.ui.composable.HomeScreen(
HomeScreen(
activeUserPublicKey = route.activeUserPublicKey,
onNavigateToEvent = { eventRoute ->
navController.navigate(
route = eventRoute
)
},
onNavigateToWriteNewNote = {
onNavigateToChatRoomCreation = {
navController.navigate(
route = WriteNewNoteRoute(
route = ChatRoomCreationRoute(
activeUserPublicKey = route.activeUserPublicKey
)
)

View File

@@ -0,0 +1,9 @@
package at.torch.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class ChatRoomCreationRoute(
val activeUserPublicKey: String,
): Route() {
}

View File

@@ -0,0 +1,125 @@
package at.torch.compose.ui.view.model
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import at.torch.compose.extensions.toHex
import at.torch.compose.nostr.Relays
import at.torch.compose.repository.ChatRepository
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.view.state.ChatRoomDetailUIState
import at.torch.compose.ui.view.state.form.ChatRoomCreationFormState
import at.torch.compose.ui.view.state.form.CreateProfileFormState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.RandomInstance
import fr.acinq.bitcoin.Crypto
import fr.acinq.phoenix.data.ActiveWallet
import fr.acinq.phoenix.managers.nostrPrivateKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class ChatRoomCreationViewModel(
val activeWalletStateFlow: StateFlow<ActiveWallet?>,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository,
val chatRoomCreationFormState: ChatRoomCreationFormState = ChatRoomCreationFormState(),
): ViewModel() {
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun createChatRoom(
onNavigateToChatRoom: (Route) -> Unit
) {
// TODO: Load keypair...
activeWalletStateFlow.value?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()?.let { nostrPrivateKey ->
val keyPair = KeyPair(
privKey = nostrPrivateKey.value.toByteArray()
)
val name = chatRoomCreationFormState.nameField.textFieldState.text
val description = chatRoomCreationFormState.descriptionField.textFieldState.text
val gid = RandomInstance.bytes(32).toHexKey() // TODO: Generate GID through frost...
// Stamp initial metadata via the shared factory so UI + CLI stay
// byte-identical. Bake the MarmotGroupData extension into the
// epoch-0 GroupContext directly (see `MarmotManager.createGroup`)
// so later invitees receive a pre-populated group from the
// welcome and never have to chase an undecryptable bootstrap
// commit that predates their membership.
val metadata = MarmotGroupData.bootstrap(
nostrGroupId = gid,
creatorPubKey = keyPair.pubKey.toHexKey(),
outboxRelays = Relays.DefaultDMRelayList.map { it.url },
name = name.toString(),
description = description.toString()
)
val extras = listOf(metadata.toExtension())
val group = MlsGroup.create(keyPair.pubKey, keyPair.privKey, extras)
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getOrCreateChatRoom(
chatRoomId = group.groupId.toHex(),
activeUserPublicKey = keyPair.pubKey.toHexKey(),
relayHint = null,
defaultSubject = null,
mlsGroupState = group.saveState().encodeTls().toHex()
)
if (localChatRoom == null) {
onNavigateToChatRoom.invoke(
ImplementationPendingRoute("Something went wrong")
)
} else {
onNavigateToChatRoom.invoke(
ImplementationPendingRoute("Chat Room view ${localChatRoom.chatRoom.id}")
)
}
}
}
}
companion object {
private const val TAG = "ChatRoomCreationViewModel"
fun factory(
activeWalletStateFlow: StateFlow<ActiveWallet?>,
nostrRepository: NostrRepository,
chatRepository: ChatRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ChatRoomCreationViewModel(
activeWalletStateFlow = activeWalletStateFlow,
nostrRepository = nostrRepository,
chatRepository = chatRepository
)
}
}
}
}

View File

@@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import at.torch.compose.ui.view.state.ChatRoomDetailUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import fr.acinq.bitcoin.Crypto
@@ -20,12 +21,12 @@ class ChatRoomDetailViewModel(
val directMessageId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialChatRoomDetailUIState: at.torch.compose.ui.view.state.ChatRoomDetailUIState,
initialChatRoomDetailUIState: ChatRoomDetailUIState,
val nostrRepository: at.torch.compose.repository.NostrRepository,
val chatRepository: at.torch.compose.repository.ChatRepository
): ViewModel() {
var chatRoomDetailUIState: at.torch.compose.ui.view.state.ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState)
var chatRoomDetailUIState: ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState)
private set
@@ -35,38 +36,17 @@ class ChatRoomDetailViewModel(
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun initiateChatRoomDetail() {
if (Crypto.isPubKeyCompressed(directMessageId.hexToByteArray())) {
logger.d("compressed (most likely chat room): $directMessageId")
// Get ChatRoomById
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(directMessageId)
logger.d("compressed (most likely chat room): $directMessageId")
// Get ChatRoomById
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(directMessageId)
chatRoomDetailUIState = if (localChatRoom == null) {
_root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Error
} else {
_root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom
)
}
}
} else {
logger.d("Uncompressed publicKey (most likely npub): $directMessageId")
viewModelScope.launch(Dispatchers.IO) {
// We need to get or create a chat with the user on this publicKey...
val localChatRoom = chatRepository.getOrCreateChatRoom(
activeUserPublicKey = activeUserPublicKey,
publicKey = directMessageId,
relayHint = relayHint
chatRoomDetailUIState = if (localChatRoom == null) {
ChatRoomDetailUIState.Error
} else {
ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom
)
chatRoomDetailUIState = if (localChatRoom == null) {
_root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Error
} else {
_root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom
)
}
}
}
}
@@ -79,7 +59,7 @@ class ChatRoomDetailViewModel(
activeUserPublicKey: HexKey,
publicKey: String,
relayHint: String?,
initialChatRoomDetailUIState: at.torch.compose.ui.view.state.ChatRoomDetailUIState = _root_ide_package_.at.torch.compose.ui.view.state.ChatRoomDetailUIState.Loading,
initialChatRoomDetailUIState: ChatRoomDetailUIState = ChatRoomDetailUIState.Loading,
nostrRepository: at.torch.compose.repository.NostrRepository,
chatRepository: at.torch.compose.repository.ChatRepository
): ViewModelProvider.Factory = viewModelFactory {

View File

@@ -0,0 +1,6 @@
package at.torch.compose.ui.view.state.form
data class ChatRoomCreationFormState(
val nameField: TextField = TextField(),
val descriptionField: TextField = TextField()
)