Add ChatRoomDetailScreen

This commit is contained in:
Kgothatso Ngako
2026-07-12 18:07:59 +02:00
parent ec48eea6d9
commit 4fbdb561b8
10 changed files with 484 additions and 23 deletions

View File

@@ -0,0 +1,346 @@
package at.torch.compose.ui.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Unsubscribe
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.database.model.ChatRoom
import at.torch.compose.database.model.Participant
import at.torch.compose.database.model.Profile
import at.torch.compose.database.model.intermdiate.LocalChatRoom
import at.torch.compose.database.model.intermdiate.LocalParticipant
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.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar
import at.torch.compose.ui.theme.TorchTheme
import at.torch.compose.ui.view.model.ChatRoomDetailViewModel
import at.torch.compose.ui.view.state.ChatRoomDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ChatRoomDetailScreen(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialChatRoomDetailUIState: ChatRoomDetailUIState = ChatRoomDetailUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository,
onNavigateToRoute: (Route) -> Unit,
) {
val chatRoomDetailViewModel: ChatRoomDetailViewModel = viewModel(
factory = ChatRoomDetailViewModel.factory(
chatRoomId = chatRoomId,
relayHint = relayHint,
initialChatRoomDetailUIState = initialChatRoomDetailUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
activeUserPublicKey = activeUserPublicKey
)
)
when (val chatRoomDetailUIState = chatRoomDetailViewModel.chatRoomDetailUIState) {
is ChatRoomDetailUIState.Error -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
modifier = Modifier.height(50.dp)
)
Text(
text = chatRoomDetailUIState.message,
)
}
}
is ChatRoomDetailUIState.Loaded -> {
Scaffold(
topBar = {
TopAppBar(
title = {
chatRoomDetailUIState.localChatRoom.RenderChatRoomTitleText()
},
)
}
) { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// TODO: Add Description...
// item {
// chatRoomDetailUIState.localChatRoom.chatRoom.subject?.let {
// Text(
// text = it
// )
// }
// }
item {
// Media and such should be here...
}
item {
Text("members")
}
item {
TextButton(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
SearchMemberToAddToChatRoomRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
)
)
}
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = "Invite new member"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Invite New Member")
}
}
items(
items = chatRoomDetailUIState.localChatRoom.localParticipants,
key = { localParticipant -> localParticipant.participant.participantPublicKey}
) { localParticipant ->
Card(
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute(
"Profile detail"
)
)
}
) {
ListItem(
leadingContent = {
ProfileAvatar(
publicKey = localParticipant.participant.participantPublicKey,
profile = localParticipant.profile
)
},
trailingContent = {
Icon(
Icons.Default.ChevronRight,
contentDescription = "View profile"
)
},
headlineContent = {
Text(
text = localParticipant.profile?.humanReadableNameOrPubkey() ?: localParticipant.participant.participantPublicKey
)
},
supportingContent = {
localParticipant.profile?.about?.let {
Text(
text = it
)
}
}
)
}
}
item {
HorizontalDivider()
}
item {
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.secondary
),
enabled = true, // Disable after we leave group...
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute(
"Leave Group"
)
)
}
) {
Icon(
Icons.Default.Unsubscribe,
contentDescription = "Leave Group"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Leave Group")
}
}
item {
TextButton(
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
enabled = true, // TODO: Only enabled after we leave group...
onClick = {
onNavigateToRoute.invoke(
ImplementationPendingRoute(
"Delete Group"
)
)
}
) {
Icon(
Icons.Default.DeleteForever,
contentDescription = "Delete Group"
)
Spacer(
modifier = Modifier.width(10.dp)
)
Text("Delete Group")
}
}
}
}
}
}
ChatRoomDetailUIState.Loading -> {
Column(
modifier = Modifier.fillMaxWidth().padding(
20.dp
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Spacer(
modifier = Modifier.weight(1f)
)
Text(
text = "Direct Message Detail",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Text(
text = "Direct message functionality will be here.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
LoadingDataIndicator(
fillScreen = false
)
Spacer(
modifier = Modifier.weight(2f)
)
}
}
}
LaunchedEffect(true) {
if (initialChatRoomDetailUIState == ChatRoomDetailUIState.Loading) {
chatRoomDetailViewModel.initiateChatRoomDetail()
}
}
}
@Preview
@Composable
private fun ChatRoomMessagingScreenPreview() {
TorchTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
ChatRoomDetailScreen(
activeUserPublicKey = "",
chatRoomId = "publicKey",
relayHint = null,
initialChatRoomDetailUIState = ChatRoomDetailUIState.Loaded(
localChatRoom = LocalChatRoom(
chatRoom = ChatRoom(
id = "",
userPublicKey = "",
subject = "Message title",
initialGiftWrapPayloadId = "sdfaer",
mlsGroupState = null
),
localParticipants = listOf(
LocalParticipant(
participant = Participant(
id = 2,
participantPublicKey = "gex",
chatRoomId = "chatRoomId",
relayHint = null
),
profile = Profile(
publicKey = "gex",
displayName = "Gexie",
nostrEventId = "nosrtEventId"
)
)
)
)
),
nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY,
chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY,
onNavigateToRoute = {}
)
}
}
}

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.BottomAppBarDefaults
@@ -41,6 +42,7 @@ import at.torch.compose.database.model.ChatRoom
import at.torch.compose.database.model.intermdiate.LocalChatRoom
import at.torch.compose.repository.ChatRepository
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import at.torch.compose.ui.composable.widgets.LoadingDataIndicator
@@ -108,7 +110,7 @@ fun ChatRoomMessagingScreen(
onClick = {
// Navigate to the invite user screen...
onNavigateToRoute.invoke(
SearchMemberToAddToChatRoomRoute(
ChatRoomDetailRoute(
chatRoomId = chatRoomId,
activeUserPublicKey = activeUserPublicKey,
relayHint = relayHint
@@ -117,8 +119,8 @@ fun ChatRoomMessagingScreen(
}
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = "Invite memeber"
Icons.Default.MoreVert,
contentDescription = "View details..."
)
}
}

View File

@@ -21,6 +21,7 @@ import at.torch.compose.managers.AuxDatabaseManager
import at.torch.compose.ui.composable.ActiveProfileScreen
import at.torch.compose.ui.composable.AddMemberToChatRoomConfirmationScreen
import at.torch.compose.ui.composable.ChatRoomCreationScreen
import at.torch.compose.ui.composable.ChatRoomDetailScreen
import at.torch.compose.ui.composable.ChatRoomMessagingScreen
import at.torch.compose.ui.composable.CreateProfileScreen
import at.torch.compose.ui.composable.HomeScreen
@@ -48,6 +49,7 @@ import at.torch.compose.ui.composable.navigation.routes.AddMemberToChatRoomConfi
import at.torch.compose.ui.composable.navigation.routes.BlankRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomCreationRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import at.torch.compose.ui.composable.navigation.routes.CreateProfileRoute
import at.torch.compose.ui.composable.navigation.routes.FeedRoute
import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute
@@ -554,8 +556,8 @@ fun TorchNavHost(
}
)
}
composable<ChatRoomDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChatRoomDetailRoute>()
composable<ChatRoomMessagingRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChatRoomMessagingRoute>()
ChatRoomMessagingScreen(
activeUserPublicKey = route.activeUserPublicKey,
@@ -579,6 +581,22 @@ fun TorchNavHost(
}
)
}
composable<ChatRoomDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChatRoomDetailRoute>()
ChatRoomDetailScreen(
activeUserPublicKey = route.activeUserPublicKey,
chatRoomId = route.chatRoomId,
relayHint = route.relayHint,
nostrRepository = databaseNostrRepository,
chatRepository = databaseChatRepository,
onNavigateToRoute = { actionRoute ->
navController.navigate(
route = actionRoute
)
}
)
}
composable<SearchMemberToAddToChatRoomRoute> { backStackEntry ->
val route = backStackEntry.toRoute<SearchMemberToAddToChatRoomRoute>()

View File

@@ -0,0 +1,10 @@
package at.torch.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
@Serializable
data class ChatRoomMessagingRoute(
val activeUserPublicKey: String,
val chatRoomId: String, // TODO: have this as a publicKey
val relayHint: String?
): Route()

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBackIosNew
@@ -27,10 +26,8 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
@@ -50,7 +47,7 @@ import at.torch.compose.database.model.NostrEvent
import at.torch.compose.database.model.Profile
import at.torch.compose.database.model.intermdiate.LocalNostrEvent
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import at.torch.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.composable.widgets.profile.ProfileAvatar
@@ -60,7 +57,6 @@ import at.torch.compose.ui.view.model.MetadataEventDetailViewModel
import at.torch.compose.ui.view.state.MetadataEventDetailUIState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -284,7 +280,7 @@ fun MetadataEventDetail(
Button(
onClick = {
onNavigateToChatRoom.invoke(
ChatRoomDetailRoute(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = localNostrEvent.nostrEvent.pubKey,
relayHint = localNostrEvent.nostrEvent.relayUrl,

View File

@@ -11,7 +11,7 @@ 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.ChatRoomDetailRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
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.form.ChatRoomCreationFormState
@@ -93,7 +93,7 @@ class ChatRoomCreationViewModel(
// TODO: Navigate to invitation screen...
onNavigateToChatRoom.invoke(
ChatRoomDetailRoute(
ChatRoomMessagingRoute(
activeUserPublicKey = keyPair.pubKey.toHexKey(),
chatRoomId = localChatRoom.chatRoom.id,
relayHint = null

View File

@@ -0,0 +1,79 @@
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.repository.ChatRepository
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.view.state.ChatRoomDetailUIState
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
class ChatRoomDetailViewModel(
val chatRoomId: String,
val activeUserPublicKey: HexKey,
val relayHint: String?,
initialChatRoomDetailUIState: ChatRoomDetailUIState,
val nostrRepository: NostrRepository,
val chatRepository: ChatRepository
): ViewModel() {
var chatRoomDetailUIState: ChatRoomDetailUIState by mutableStateOf(initialChatRoomDetailUIState)
private set
private val logger = Logger.withTag(TAG)
val isActionPending: MutableState<Boolean> = mutableStateOf(false)
fun initiateChatRoomDetail() {
logger.d("compressed (most likely chat room): $chatRoomId")
// Get ChatRoomById
viewModelScope.launch(Dispatchers.IO) {
val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId)
chatRoomDetailUIState = if (localChatRoom == null) {
// Initiate new chat... and navigate to new chat...
ChatRoomDetailUIState.Error("Missing chat room")
} else {
ChatRoomDetailUIState.Loaded(
localChatRoom = localChatRoom
)
}
}
}
companion object {
private const val TAG = "ChatRoomDetailViewModel"
fun factory(
activeUserPublicKey: HexKey,
chatRoomId: String,
relayHint: String?,
initialChatRoomDetailUIState: ChatRoomDetailUIState = ChatRoomDetailUIState.Loading,
nostrRepository: NostrRepository,
chatRepository: ChatRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
ChatRoomDetailViewModel(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint,
initialChatRoomDetailUIState = initialChatRoomDetailUIState,
nostrRepository = nostrRepository,
chatRepository = chatRepository
)
}
}
}
}

View File

@@ -30,15 +30,10 @@ import at.torch.compose.database.model.types.SynchronizationFilter
import at.torch.compose.nostr.Relays
import co.touchlab.kermit.Logger
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupState
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import io.ktor.utils.io.core.toByteArray
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.launch
import kotlin.math.log
import kotlin.time.Instant
class ChatRoomListViewModel(
initialChatRoomListUIState: at.torch.compose.ui.view.state.ChatRoomListUIState,
@@ -179,7 +174,7 @@ class ChatRoomListViewModel(
modifier = Modifier.fillMaxWidth(),
onClick = {
onNavigateToDirectMessageDetail.invoke(
_root_ide_package_.at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute(
_root_ide_package_.at.torch.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute(
activeUserPublicKey = publicKey,
chatRoomId = localChatRoom.chatRoom.id,
relayHint = localChatRoom.localParticipants.firstOrNull { it.participant.participantPublicKey != localChatRoom.chatRoom.userPublicKey }?.participant?.relayHint

View File

@@ -11,7 +11,7 @@ import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import at.torch.compose.repository.ChatRepository
import at.torch.compose.repository.NostrRepository
import at.torch.compose.ui.composable.navigation.routes.ChatRoomDetailRoute
import at.torch.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute
import at.torch.compose.ui.composable.navigation.routes.Route
import at.torch.compose.ui.view.state.ChatRoomMessagingUIState
import co.touchlab.kermit.Logger
@@ -73,7 +73,7 @@ class ChatRoomMessagingViewModel(
// TODO: navigateToTheChat...
viewModelScope.launch(Dispatchers.Main) {
onNavigateToChat.invoke(
ChatRoomDetailRoute(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = localChatRoom.chatRoom.id,
relayHint = relayHint
@@ -89,7 +89,7 @@ class ChatRoomMessagingViewModel(
// We have found the chat we are looking for...
viewModelScope.launch(Dispatchers.Main) {
onNavigateToChat.invoke(
ChatRoomDetailRoute(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = localChatRoom.chatRoom.id,
relayHint = relayHint
@@ -121,7 +121,7 @@ class ChatRoomMessagingViewModel(
)
onNavigateToChat.invoke(
ChatRoomDetailRoute(
ChatRoomMessagingRoute(
activeUserPublicKey = activeUserPublicKey,
chatRoomId = chatRoomId,
relayHint = relayHint

View File

@@ -0,0 +1,15 @@
package at.torch.compose.ui.view.state
import at.torch.compose.database.model.intermdiate.LocalChatRoom
sealed interface ChatRoomDetailUIState {
data class Loaded(
val localChatRoom: LocalChatRoom
): ChatRoomDetailUIState
data class Error(
val message: String
): ChatRoomDetailUIState
data object Loading: ChatRoomDetailUIState
}