re organizing the private dm composables

This commit is contained in:
Vitor Pamplona 2025-03-07 15:11:17 -05:00
parent 639e2bb645
commit 429f9f81c4
29 changed files with 1795 additions and 1542 deletions

View File

@ -61,8 +61,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
@ -244,7 +244,7 @@ fun AppNavigation(
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
ChatroomScreenByAuthor(it.id(), null, accountViewModel, nav)
ChatroomByAuthorScreen(it.id(), null, accountViewModel, nav)
}
composable(

View File

@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable

View File

@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer

View File

@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.ChatroomHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText

View File

@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@ -54,15 +53,12 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.ui.navigation.INav
@ -71,9 +67,9 @@ import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@ -85,7 +81,6 @@ import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import kotlin.math.min
@Composable
fun ChatroomHeaderCompose(
@ -303,131 +298,6 @@ private fun UserRoomCompose(
)
}
@Composable
fun RoomNameDisplay(
room: ChatroomKey,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val roomSubject by
accountViewModel
.userProfile()
.live()
.messages
.map { it.user.privateChatrooms[room]?.subject }
.distinctUntilChanged()
.observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
CrossfadeIfEnabled(targetState = roomSubject, modifier, label = "RoomNameDisplay", accountViewModel = accountViewModel) {
if (!it.isNullOrBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
DisplayUserAndSubject(room.users.first(), it, accountViewModel)
}
} else {
DisplayUserSetAsSubject(room, accountViewModel)
}
}
}
@Composable
private fun DisplayUserAndSubject(
user: HexKey,
subject: String,
accountViewModel: AccountViewModel,
) {
Row {
Text(
text = subject,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = " - ",
fontWeight = FontWeight.Bold,
maxLines = 1,
)
LoadUser(baseUserHex = user, accountViewModel = accountViewModel) {
it?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
}
}
}
@Composable
fun DisplayUserSetAsSubject(
room: ChatroomKey,
accountViewModel: AccountViewModel,
fontWeight: FontWeight = FontWeight.Bold,
) {
val userList = remember(room) { room.users.toList() }
if (userList.size == 1) {
// Regular Design
Row {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight, accountViewModel = accountViewModel) }
}
}
} else {
Row {
userList.take(4).forEachIndexed { index, value ->
LoadUser(baseUserHex = value, accountViewModel) {
it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight, accountViewModel = accountViewModel) }
}
if (min(userList.size, 4) - 1 != index) {
Text(
text = ", ",
fontWeight = fontWeight,
maxLines = 1,
)
}
}
}
}
}
@Composable
fun DisplayRoomSubject(
roomSubject: String,
fontWeight: FontWeight = FontWeight.Bold,
) {
Row {
Text(
text = roomSubject,
fontWeight = fontWeight,
maxLines = 1,
)
}
}
@Composable
fun ShortUsernameDisplay(
baseUser: User,
weight: Modifier = Modifier,
fontWeight: FontWeight = FontWeight.Bold,
accountViewModel: AccountViewModel,
) {
val userName by
baseUser
.live()
.metadata
.map { it.user.toBestShortFirstName() }
.distinctUntilChanged()
.observeAsState(baseUser.toBestShortFirstName())
CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) {
CreateTextWithEmoji(
text = it,
tags = baseUser.info?.tags,
fontWeight = fontWeight,
maxLines = 1,
)
}
}
@Composable
fun LoadUser(
baseUserHex: String,

View File

@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.Chatroom
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.Channel
import com.vitorpamplona.amethyst.ui.theme.Size20dp

View File

@ -1,985 +0,0 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff
import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.DisplayRoomSubject
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.DisplayUserSetAsSubject
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@Composable
fun ChatroomScreen(
roomId: String?,
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (roomId == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
RoomTopBar(roomId, accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
Chatroom(roomId, draftMessage, replyToNote, editFromDraft, accountViewModel, nav)
}
}
}
@Composable
private fun RoomTopBar(
id: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadRoom(roomId = id, accountViewModel) { room ->
if (room != null) {
RenderRoomTopBar(room, accountViewModel, nav)
} else {
Spacer(BottomTopHeight)
}
}
}
@Composable
private fun RenderRoomTopBar(
room: ChatroomKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (room.users.size == 1) {
TopBarExtensibleWithBackButton(
title = {
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size34dp,
)
Spacer(modifier = DoubleHorzSpacer)
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
}
}
},
extendableRow = {
LoadUser(baseUserHex = room.users.first(), accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
},
popBack = nav::popBack,
)
} else {
TopBarExtensibleWithBackButton(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel)
}
},
extendableRow = {
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
},
popBack = nav::popBack,
)
}
}
@Composable
fun Chatroom(
roomId: String?,
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (roomId == null) return
LoadRoom(roomId, accountViewModel) {
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun ChatroomScreenByAuthor(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (authorPubKeyHex == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav)
}
}
}
@Composable
private fun RoomByAuthorTopBar(
authorPubKeyHex: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadRoomByAuthor(authorPubKeyHex = authorPubKeyHex, accountViewModel) { room ->
if (room != null) {
RenderRoomTopBar(room, accountViewModel, nav)
} else {
Spacer(BottomTopHeight)
}
}
}
@Composable
fun ChatroomByAuthor(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (authorPubKeyHex == null) return
LoadRoomByAuthor(authorPubKeyHex, accountViewModel) {
it?.let {
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
replyToNote = null,
editFromDraft = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun LoadRoom(
roomId: String,
accountViewModel: AccountViewModel,
content: @Composable (ChatroomKey?) -> Unit,
) {
var room by remember(roomId) { mutableStateOf<ChatroomKey?>(null) }
if (room == null) {
LaunchedEffect(key1 = roomId) {
launch(Dispatchers.IO) {
val newRoom =
accountViewModel.userProfile().privateChatrooms.keys.firstOrNull {
it.hashCode().toString() == roomId
}
if (room != newRoom) {
room = newRoom
}
}
}
}
content(room)
}
@Composable
fun LoadRoomByAuthor(
authorPubKeyHex: String,
accountViewModel: AccountViewModel,
content: @Composable (ChatroomKey?) -> Unit,
) {
val room by
remember(authorPubKeyHex) {
mutableStateOf<ChatroomKey?>(ChatroomKey(persistentSetOf(authorPubKeyHex)))
}
content(room)
}
@Composable
fun PrepareChatroomViewModels(
room: ChatroomKey,
draftMessage: String?,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedViewModel: NostrChatroomFeedViewModel =
viewModel(
key = room.hashCode().toString() + "ChatroomViewModels",
factory =
NostrChatroomFeedViewModel.Factory(
room,
accountViewModel.account,
),
)
val newPostModel: ChatNewMessageViewModel = viewModel()
newPostModel.init(accountViewModel)
newPostModel.load(room)
if (replyToNote != null) {
LaunchedEffect(key1 = replyToNote) {
accountViewModel.checkGetOrCreateNote(replyToNote) {
if (it != null) {
newPostModel.reply(it)
}
}
}
}
if (editFromDraft != null) {
LaunchedEffect(key1 = replyToNote) {
accountViewModel.checkGetOrCreateNote(editFromDraft) {
if (it != null) {
newPostModel.editFromDraft(it)
}
}
}
}
if (room.users.size == 1) {
// Activates NIP-17 if the user has DM relays
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
if (it?.relays().isNullOrEmpty()) {
newPostModel.nip17 = false
} else {
newPostModel.nip17 = true
}
}
}
val imageUpload: ChatFileUploadModel = viewModel()
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
}
}
ChatroomScreen(
room = room,
feedViewModel = feedViewModel,
newPostModel = newPostModel,
fileUpload = imageUpload,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun ChatroomScreen(
room: ChatroomKey,
feedViewModel: NostrChatroomFeedViewModel,
newPostModel: ChatNewMessageViewModel,
fileUpload: ChatFileUploadModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(room, accountViewModel) {
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
onDispose { NostrChatroomDataSource.stop() }
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Private Message Stop")
NostrChatroomDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
Column(Modifier.fillMaxHeight()) {
ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav)
Column(
modifier =
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true),
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
}
Spacer(modifier = Modifier.height(10.dp))
newPostModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { newPostModel.replyTo.value = null } }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = newPostModel.draftTag) {
launch(Dispatchers.IO) {
newPostModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
newPostModel.sendDraft()
}
}
}
fileUpload.multiOrchestrator?.let {
ChatFileUploadView(
fileUpload,
onClose = fileUpload::cancelModel,
accountViewModel,
nav,
)
}
// LAST ROW
PrivateMessageEditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch(Dispatchers.IO) {
newPostModel.sendPostSync()
feedViewModel.sendToTop()
}
},
onSendNewMedia = { list, isNip17 ->
fileUpload.load(list, room, isNip17, accountViewModel.account)
},
)
}
}
@Composable
fun PrivateMessageEditFieldRow(
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
onSendNewMedia: (ImmutableList<SelectedMedia>, isNip17: Boolean) -> Unit,
) {
Column(
modifier = EditFieldModifier,
) {
ShowUserSuggestionList(
channelScreenModel.userSuggestions.userSuggestions,
channelScreenModel::autocompleteWithUser,
accountViewModel,
)
ShowEmojiSuggestionList(
channelScreenModel.emojiSuggestions,
channelScreenModel::autocompleteWithEmoji,
channelScreenModel::autocompleteWithEmojiUrl,
accountViewModel,
)
ThinPaddingTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
shape = EditFieldBorder,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringRes(R.string.reply_here),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
ThinSendButton(
isActive =
channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = EditFieldTrailingIconModifier,
) {
onSendNewMessage()
}
},
leadingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 6.dp),
) {
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.size(30.dp).padding(start = 2.dp),
onImageChosen = {
onSendNewMedia(it, channelScreenModel.nip17)
},
)
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
if (wantsToActivateNIP17) {
NewFeatureNIP17AlertDialog(
accountViewModel = accountViewModel,
onConfirm = { channelScreenModel.toggleNIP04And24() },
onDismiss = { wantsToActivateNIP17 = false },
)
}
IconButton(
modifier = Size30Modifier,
onClick = {
if (
!accountViewModel.account.settings.hideNIP17WarningDialog &&
!channelScreenModel.nip17 &&
!channelScreenModel.requiresNIP17
) {
wantsToActivateNIP17 = true
} else {
channelScreenModel.toggleNIP04And24()
}
},
) {
if (channelScreenModel.nip17) {
IncognitoIconOn(
modifier =
Modifier
.padding(top = 2.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else {
IncognitoIconOff(
modifier =
Modifier
.padding(top = 2.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
)
}
}
@Composable
fun NewFeatureNIP17AlertDialog(
accountViewModel: AccountViewModel,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringRes(R.string.new_feature_nip17_might_not_be_available_title),
textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description),
buttonIconResource = R.drawable.incognito,
buttonText = stringRes(R.string.new_feature_nip17_activate),
onClickDoOnce = {
scope.launch { onConfirm() }
onDismiss()
},
onClickDontShowAgain = {
scope.launch {
onConfirm()
accountViewModel.account.settings.setHideNIP17WarningDialog()
}
onDismiss()
},
onDismiss = onDismiss,
)
}
@Composable
fun ThinSendButton(
isActive: Boolean,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
enabled = isActive,
modifier = modifier,
onClick = onClick,
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = stringRes(id = R.string.accessibility_send),
modifier = Size20Modifier,
)
}
}
@Composable
fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
if (room.users.size == 1) {
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ChatroomHeader(
baseUser = baseUser,
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
)
}
}
} else {
GroupChatroomHeader(
room = room,
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
)
}
}
@Composable
fun ChatroomHeader(
baseUser: User,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable(
onClick = onClick,
),
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size34dp,
)
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
}
}
}
}
}
@Composable
fun GroupChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Bold, accountViewModel)
}
}
}
}
@Composable
private fun EditRoomSubjectButton(
room: ChatroomKey,
accountViewModel: AccountViewModel,
) {
var wantsToPost by remember { mutableStateOf(false) }
if (wantsToPost) {
NewSubjectView({ wantsToPost = false }, accountViewModel, room)
}
Button(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { wantsToPost = true },
contentPadding = ZeroPadding,
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = stringRes(R.string.edits_the_channel_metadata),
)
}
}
@Composable
fun NewSubjectView(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
room: ChatroomKey,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
val groupName =
remember {
mutableStateOf<String>(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "")
}
val message = remember { mutableStateOf<String>("") }
val scope = rememberCoroutineScope()
Column(
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
val template =
ChatMessageEvent.build(
message.value,
room.users.map { LocalCache.getOrCreateUser(it).toPTag() },
) {
groupName.value.ifBlank { null }?.let { changeSubject(it) }
}
accountViewModel.account.sendNIP17PrivateMessage(template)
}
onClose()
},
true,
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.messages_new_message_subject)) },
modifier = Modifier.fillMaxWidth(),
value = groupName.value,
onValueChange = { groupName.value = it },
placeholder = {
Text(
text = stringRes(R.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.messages_new_subject_message)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
Text(
text = stringRes(R.string.messages_new_subject_message_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
}
}
}
}
@Composable
fun LongRoomHeader(
room: ChatroomKey,
lineModifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: INav,
) {
val list = remember(room) { room.users.toPersistentList() }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.messages_group_descriptor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
EditRoomSubjectButton(room, accountViewModel)
}
LazyColumn(
modifier = Modifier,
state = rememberLazyListState(),
) {
itemsIndexed(list, key = { _, item -> item }) { _, item ->
LoadUser(baseUserHex = item, accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
overallModifier = lineModifier,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
}
@Composable
fun RoomNameOnlyDisplay(
room: ChatroomKey,
modifier: Modifier,
fontWeight: FontWeight = FontWeight.Bold,
accountViewModel: AccountViewModel,
) {
val roomSubject by
accountViewModel
.userProfile()
.live()
.messages
.map { it.user.privateChatrooms[room]?.subject }
.distinctUntilChanged()
.observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) {
if (!it.isNullOrBlank()) {
DisplayRoomSubject(it, fontWeight)
} else {
DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal)
}
}
}

View File

@ -0,0 +1,110 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.collections.immutable.persistentSetOf
@Composable
fun ChatroomByAuthorScreen(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (authorPubKeyHex == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
RoomByAuthorTopBar(authorPubKeyHex, accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
ChatroomByAuthor(authorPubKeyHex, draftMessage, accountViewModel, nav)
}
}
}
@Composable
fun LoadRoomByAuthor(
authorPubKeyHex: String,
accountViewModel: AccountViewModel,
content: @Composable (ChatroomKey?) -> Unit,
) {
val room by remember(authorPubKeyHex) {
mutableStateOf<ChatroomKey?>(ChatroomKey(persistentSetOf(authorPubKeyHex)))
}
content(room)
}
@Composable
private fun RoomByAuthorTopBar(
authorPubKeyHex: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadRoomByAuthor(authorPubKeyHex = authorPubKeyHex, accountViewModel) { room ->
if (room != null) {
RenderRoomTopBar(room, accountViewModel, nav)
} else {
Spacer(BottomTopHeight)
}
}
}
@Composable
fun ChatroomByAuthor(
authorPubKeyHex: String?,
draftMessage: String? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (authorPubKeyHex == null) return
LoadRoomByAuthor(authorPubKeyHex, accountViewModel) {
it?.let {
ChatroomView(
room = it,
draftMessage = draftMessage,
replyToNote = null,
editFromDraft = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}

View File

@ -0,0 +1,130 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ChatroomScreen(
roomId: String?,
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (roomId == null) return
DisappearingScaffold(
isInvertedLayout = true,
topBar = {
RoomTopBar(roomId, accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
Chatroom(roomId, draftMessage, replyToNote, editFromDraft, accountViewModel, nav)
}
}
}
@Composable
private fun RoomTopBar(
id: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadRoom(roomId = id, accountViewModel) { room ->
if (room != null) {
RenderRoomTopBar(room, accountViewModel, nav)
} else {
Spacer(BottomTopHeight)
}
}
}
@Composable
fun Chatroom(
roomId: String?,
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (roomId == null) return
LoadRoom(roomId, accountViewModel) {
it?.let {
ChatroomView(
room = it,
draftMessage = draftMessage,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
fun LoadRoom(
roomId: String,
accountViewModel: AccountViewModel,
content: @Composable (ChatroomKey?) -> Unit,
) {
var room by remember(roomId) { mutableStateOf<ChatroomKey?>(null) }
if (room == null) {
LaunchedEffect(key1 = roomId) {
launch(Dispatchers.IO) {
val newRoom =
accountViewModel.userProfile().privateChatrooms.keys.firstOrNull {
it.hashCode().toString() == roomId
}
if (room != newRoom) {
room = newRoom
}
}
}
}
content(room)
}

View File

@ -0,0 +1,195 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMs
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForDMsAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.coroutines.launch
@Composable
fun ChatroomView(
room: ChatroomKey,
draftMessage: String?,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedViewModel: NostrChatroomFeedViewModel =
viewModel(
key = room.hashCode().toString() + "ChatroomViewModels",
factory =
NostrChatroomFeedViewModel.Factory(
room,
accountViewModel.account,
),
)
val newPostModel: ChatNewMessageViewModel = viewModel()
newPostModel.init(accountViewModel)
newPostModel.load(room)
if (replyToNote != null) {
LaunchedEffect(key1 = replyToNote) {
accountViewModel.checkGetOrCreateNote(replyToNote) {
if (it != null) {
newPostModel.reply(it)
}
}
}
}
if (editFromDraft != null) {
LaunchedEffect(key1 = replyToNote) {
accountViewModel.checkGetOrCreateNote(editFromDraft) {
if (it != null) {
newPostModel.editFromDraft(it)
}
}
}
}
if (room.users.size == 1) {
// Activates NIP-17 if the user has DM relays
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
if (it?.relays().isNullOrEmpty()) {
newPostModel.nip17 = false
} else {
newPostModel.nip17 = true
}
}
}
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
}
}
ChatroomViewUI(
room = room,
feedViewModel = feedViewModel,
newPostModel = newPostModel,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun ChatroomViewUI(
room: ChatroomKey,
feedViewModel: NostrChatroomFeedViewModel,
newPostModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(room, accountViewModel) {
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
onDispose { NostrChatroomDataSource.stop() }
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("Private Message Stop")
NostrChatroomDataSource.stop()
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
Column(Modifier.fillMaxHeight()) {
ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav)
Column(
modifier =
Modifier
.fillMaxHeight()
.padding(vertical = 0.dp)
.weight(1f, true),
) {
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
}
Spacer(modifier = Modifier.height(10.dp))
val scope = rememberCoroutineScope()
// LAST ROW
PrivateMessageEditFieldRow(
newPostModel,
accountViewModel,
onSendNewMessage = {
scope.launch {
feedViewModel.sendToTop()
}
},
nav,
)
}
}

View File

@ -0,0 +1,53 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
@Composable
fun ChatDivisor(info: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) {
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = DividerThickness,
)
Text(
text = info,
fontWeight = FontWeight.Bold,
fontSize = Font14SP,
modifier = HalfPadding,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = DividerThickness,
)
}
}

View File

@ -18,23 +18,18 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
@ -49,12 +44,9 @@ import com.vitorpamplona.amethyst.ui.note.dateFormatter
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.SaveableFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
@ -101,13 +93,10 @@ fun RenderChatroomFeedView(
CrossfadeIfEnabled(targetState = feedState, animationSpec = tween(durationMillis = 100), accountViewModel = accountViewModel) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty { viewModel.invalidateData() }
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) { viewModel.invalidateData() }
}
is FeedState.Loaded -> {
is FeedState.Loading -> LoadingFeed()
is FeedState.Empty -> FeedEmpty { viewModel.invalidateData() }
is FeedState.FeedError -> FeedError(state.errorMessage) { viewModel.invalidateData() }
is FeedState.Loaded ->
ChatroomFeedLoaded(
state,
accountViewModel,
@ -118,10 +107,6 @@ fun RenderChatroomFeedView(
onWantsToEditDraft,
avoidDraft,
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@ -163,14 +148,14 @@ fun ChatroomFeedLoaded(
onWantsToEditDraft = onWantsToEditDraft,
)
NewDateSubject(items.list.getOrNull(index + 1), item)
NewDateOrSubjectDivisor(items.list.getOrNull(index + 1), item)
}
}
}
}
@Composable
fun NewDateSubject(
fun NewDateOrSubjectDivisor(
previous: Note?,
note: Note,
) {
@ -196,23 +181,3 @@ fun NewDateSubject(
}
}
}
@Composable
fun ChatDivisor(info: String) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = StdPadding) {
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = DividerThickness,
)
Text(
text = info,
fontWeight = FontWeight.Bold,
fontSize = Font14SP,
modifier = HalfPadding,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = DividerThickness,
)
}
}

View File

@ -0,0 +1,130 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@Composable
fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
if (room.users.size == 1) {
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
UserChatroomHeader(
baseUser = baseUser,
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
)
}
}
} else {
GroupChatroomHeader(
room = room,
modifier = modifier,
accountViewModel = accountViewModel,
onClick = onClick,
)
}
}
@Composable
fun UserChatroomHeader(
baseUser: User,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
Modifier
.fillMaxWidth()
.clickable(
onClick = onClick,
),
) {
Column(modifier, Arrangement.Center) {
Row(verticalAlignment = Alignment.CenterVertically) {
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size34dp,
)
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser, accountViewModel = accountViewModel)
}
}
}
}
}
@Composable
fun GroupChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(
verticalArrangement = Arrangement.Center,
modifier = modifier,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Bold, accountViewModel)
}
}
}
}

View File

@ -0,0 +1,162 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewChatroomSubjectDialog(
onClose: () -> Unit,
accountViewModel: AccountViewModel,
room: ChatroomKey,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
dismissOnClickOutside = false,
),
) {
Surface {
val groupName =
remember {
mutableStateOf<String>(accountViewModel.userProfile().privateChatrooms[room]?.subject ?: "")
}
val message = remember { mutableStateOf<String>("") }
val scope = rememberCoroutineScope()
Column(
modifier =
Modifier
.padding(10.dp)
.verticalScroll(rememberScrollState()),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(onPress = { onClose() })
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
val template =
ChatMessageEvent.build(
message.value,
room.users.map { LocalCache.getOrCreateUser(it).toPTag() },
) {
groupName.value.ifBlank { null }?.let { changeSubject(it) }
}
accountViewModel.account.sendNIP17PrivateMessage(template)
}
onClose()
},
true,
)
}
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.messages_new_message_subject)) },
modifier = Modifier.fillMaxWidth(),
value = groupName.value,
onValueChange = { groupName.value = it },
placeholder = {
Text(
text = stringRes(R.string.messages_new_message_subject_caption),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(15.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.messages_new_subject_message)) },
modifier =
Modifier
.fillMaxWidth()
.height(100.dp),
value = message.value,
onValueChange = { message.value = it },
placeholder = {
Text(
text = stringRes(R.string.messages_new_subject_message_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
maxLines = 10,
)
}
}
}
}

View File

@ -0,0 +1,198 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.collections.immutable.toPersistentList
@Composable
fun RenderRoomTopBar(
room: ChatroomKey,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (room.users.size == 1) {
TopBarExtensibleWithBackButton(
title = {
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size34dp,
)
Spacer(modifier = DoubleHorzSpacer)
UsernameDisplay(baseUser, Modifier.weight(1f), fontWeight = FontWeight.Normal, accountViewModel = accountViewModel)
}
}
},
extendableRow = {
LoadUser(baseUserHex = room.users.first(), accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
},
popBack = nav::popBack,
)
} else {
TopBarExtensibleWithBackButton(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel)
}
},
extendableRow = {
GroupMembersHeader(room = room, accountViewModel = accountViewModel, nav = nav)
},
popBack = nav::popBack,
)
}
}
@Composable
fun GroupMembersHeader(
room: ChatroomKey,
lineModifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: INav,
) {
val list = remember(room) { room.users.toPersistentList() }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.messages_group_descriptor),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
)
EditRoomSubjectButton(room, accountViewModel)
}
LazyColumn(
modifier = Modifier,
state = rememberLazyListState(),
) {
itemsIndexed(list, key = { _, item -> item }) { _, item ->
LoadUser(baseUserHex = item, accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
overallModifier = lineModifier,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
}
}
}
}
}
@Composable
private fun EditRoomSubjectButton(
room: ChatroomKey,
accountViewModel: AccountViewModel,
) {
var wantsToPost by remember { mutableStateOf(false) }
if (wantsToPost) {
NewChatroomSubjectDialog({ wantsToPost = false }, accountViewModel, room)
}
Button(
modifier =
Modifier
.padding(horizontal = 3.dp)
.width(50.dp),
onClick = { wantsToPost = true },
contentPadding = ZeroPadding,
) {
Icon(
tint = Color.White,
imageVector = Icons.Default.EditNote,
contentDescription = stringRes(R.string.edits_the_channel_metadata),
)
}
}

View File

@ -0,0 +1,192 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.LoadUser
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlin.math.min
@Composable
fun RoomNameOnlyDisplay(
room: ChatroomKey,
modifier: Modifier,
fontWeight: FontWeight = FontWeight.Bold,
accountViewModel: AccountViewModel,
) {
val roomSubject by
accountViewModel
.userProfile()
.live()
.messages
.map { it.user.privateChatrooms[room]?.subject }
.distinctUntilChanged()
.observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
CrossfadeIfEnabled(targetState = roomSubject, modifier, accountViewModel = accountViewModel) {
if (!it.isNullOrBlank()) {
DisplayRoomSubject(it, fontWeight)
} else {
DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal)
}
}
}
@Composable
fun DisplayUserSetAsSubject(
room: ChatroomKey,
accountViewModel: AccountViewModel,
fontWeight: FontWeight = FontWeight.Bold,
) {
val userList = remember(room) { room.users.toList() }
if (userList.size == 1) {
// Regular Design
Row {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let { UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight, accountViewModel = accountViewModel) }
}
}
} else {
Row {
userList.take(4).forEachIndexed { index, value ->
LoadUser(baseUserHex = value, accountViewModel) {
it?.let { ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight, accountViewModel = accountViewModel) }
}
if (min(userList.size, 4) - 1 != index) {
Text(
text = ", ",
fontWeight = fontWeight,
maxLines = 1,
)
}
}
}
}
}
@Composable
fun RoomNameDisplay(
room: ChatroomKey,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val roomSubject by
accountViewModel
.userProfile()
.live()
.messages
.map { it.user.privateChatrooms[room]?.subject }
.distinctUntilChanged()
.observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
CrossfadeIfEnabled(targetState = roomSubject, modifier, label = "RoomNameDisplay", accountViewModel = accountViewModel) {
if (!it.isNullOrBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
DisplayUserAndSubject(room.users.first(), it, accountViewModel)
}
} else {
DisplayUserSetAsSubject(room, accountViewModel)
}
}
}
@Composable
fun DisplayRoomSubject(
roomSubject: String,
fontWeight: FontWeight = FontWeight.Bold,
) {
Row {
Text(
text = roomSubject,
fontWeight = fontWeight,
maxLines = 1,
)
}
}
@Composable
private fun DisplayUserAndSubject(
user: HexKey,
subject: String,
accountViewModel: AccountViewModel,
) {
Row {
Text(
text = subject,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = " - ",
fontWeight = FontWeight.Bold,
maxLines = 1,
)
LoadUser(baseUserHex = user, accountViewModel = accountViewModel) {
it?.let { UsernameDisplay(it, Modifier.weight(1f), accountViewModel = accountViewModel) }
}
}
}
@Composable
fun ShortUsernameDisplay(
baseUser: User,
weight: Modifier = Modifier,
fontWeight: FontWeight = FontWeight.Bold,
accountViewModel: AccountViewModel,
) {
val userName by
baseUser
.live()
.metadata
.map { it.user.toBestShortFirstName() }
.distinctUntilChanged()
.observeAsState(baseUser.toBestShortFirstName())
CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) {
CreateTextWithEmoji(
text = it,
tags = baseUser.info?.tags,
fontWeight = fontWeight,
maxLines = 1,
)
}
}

View File

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
@ -60,6 +60,118 @@ import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatBubbleLayout(
isLoggedInUser: Boolean,
isDraft: Boolean,
innerQuote: Boolean,
isComplete: Boolean,
hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean,
parentBackgroundColor: MutableState<Color>? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
val otherColors = MaterialTheme.colorScheme.chatBackground
val defaultBackground = MaterialTheme.colorScheme.background
val draftColor = MaterialTheme.colorScheme.chatDraftBackground
val backgroundBubbleColor =
remember {
if (isLoggedInUser) {
if (isDraft) {
mutableStateOf(
draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
} else {
mutableStateOf(
loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
}
} else {
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
}
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
) {
val popupExpanded = remember { mutableStateOf(false) }
val showDetails =
remember {
mutableStateOf(
if (isComplete) {
true
} else {
hasDetailsToShow
},
)
}
val clickableModifier =
remember {
Modifier.combinedClickable(
onClick = {
if (!onClick()) {
if (!isComplete) {
showDetails.value = !showDetails.value
}
}
},
onLongClick = { popupExpanded.value = true },
)
}
Row(
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) {
Surface(
color = backgroundBubbleColor.value,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
modifier = clickableModifier,
) {
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
if (drawAuthorInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick),
) {
drawAuthorLine()
}
}
inner(backgroundBubbleColor)
if (showDetails.value) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
detailRow()
}
}
}
}
}
if (popupExpanded.value) {
actionMenu {
popupExpanded.value = false
}
}
}
}
@Preview
@Composable
private fun BubblePreview() {
@ -207,115 +319,3 @@ private fun BubblePreview() {
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatBubbleLayout(
isLoggedInUser: Boolean,
isDraft: Boolean,
innerQuote: Boolean,
isComplete: Boolean,
hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean,
parentBackgroundColor: MutableState<Color>? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
val otherColors = MaterialTheme.colorScheme.chatBackground
val defaultBackground = MaterialTheme.colorScheme.background
val draftColor = MaterialTheme.colorScheme.chatDraftBackground
val backgroundBubbleColor =
remember {
if (isLoggedInUser) {
if (isDraft) {
mutableStateOf(
draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
} else {
mutableStateOf(
loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
}
} else {
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
}
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
) {
val popupExpanded = remember { mutableStateOf(false) }
val showDetails =
remember {
mutableStateOf(
if (isComplete) {
true
} else {
hasDetailsToShow
},
)
}
val clickableModifier =
remember {
Modifier.combinedClickable(
onClick = {
if (!onClick()) {
if (!isComplete) {
showDetails.value = !showDetails.value
}
}
},
onLongClick = { popupExpanded.value = true },
)
}
Row(
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) {
Surface(
color = backgroundBubbleColor.value,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
modifier = clickableModifier,
) {
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
if (drawAuthorInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick),
) {
drawAuthorLine()
}
}
inner(backgroundBubbleColor)
if (showDetails.value) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
detailRow()
}
}
}
}
}
if (popupExpanded.value) {
actionMenu {
popupExpanded.value = false
}
}
}
}

View File

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column

View File

@ -18,20 +18,17 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.currentWord
import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord
@ -41,17 +38,14 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploader
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
@ -101,6 +95,7 @@ open class ChatNewMessageViewModel : ViewModel() {
val replyTo = mutableStateOf<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
val iMetaAttachments = IMetaAttachments()
var message by mutableStateOf(TextFieldValue(""))
@ -134,9 +129,6 @@ open class ChatNewMessageViewModel : ViewModel() {
var toUsers by mutableStateOf(TextFieldValue(""))
var subject by mutableStateOf(TextFieldValue(""))
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
@ -168,8 +160,13 @@ open class ChatNewMessageViewModel : ViewModel() {
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
this.account = accountVM.account
this.canAddInvoice = accountVM.userProfile().info?.lnAddress() != null
this.canAddZapRaiser = accountVM.userProfile().info?.lnAddress() != null
this.canAddInvoice = hasLnAddress()
this.canAddZapRaiser = hasLnAddress()
this.uploadState =
ChatFileUploadState(
account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0],
)
}
open fun load(room: ChatroomKey) {
@ -285,11 +282,10 @@ open class ChatNewMessageViewModel : ViewModel() {
urlPreview = findUrlInMessage()
}
fun sendPost() {
fun sendPost(onDone: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(null)
accountViewModel?.deleteDraft(draftTag)
cancel()
sendPostSync()
onDone()
}
}
@ -313,6 +309,26 @@ open class ChatNewMessageViewModel : ViewModel() {
}
}
fun pickedMedia(list: ImmutableList<SelectedMedia>) {
uploadState?.load(list)
}
fun upload(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
val room = room ?: return
val account = account ?: return
val uploadState = uploadState ?: return
if (nip17) {
ChatFileUploader(room, account).uploadNIP17(uploadState, viewModelScope, onError, context, onceUploaded)
} else {
ChatFileUploader(room, account).uploadNIP04(uploadState, viewModelScope, onError, context, onceUploaded)
}
}
private fun innerSendPost(dTag: String?) {
val room = room ?: return
val accountViewModel = accountViewModel ?: return
@ -370,54 +386,6 @@ open class ChatNewMessageViewModel : ViewModel() {
}
}
fun upload(
alt: String?,
contentWarningReason: String?,
mediaQuality: Int,
isPrivate: Boolean = false,
server: ServerName,
onError: (title: String, message: String) -> Unit,
context: Context,
) {
viewModelScope.launch(Dispatchers.Default) {
val myAccount = account ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launch
isUploadingImage = true
val results =
myMultiOrchestrator.upload(
viewModelScope,
alt,
contentWarningReason,
MediaCompressor.intToCompressorQuality(mediaQuality),
server,
myAccount,
context,
)
if (results.allGood) {
results.successful.forEach {
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
iMetaAttachments.add(it.result, alt, contentWarningReason)
message = message.insertUrlAtCursor(it.result.url)
urlPreview = findUrlInMessage()
}
}
multiOrchestrator = null
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
}
}
open fun cancel() {
message = TextFieldValue("")
toUsers = TextFieldValue("")
@ -425,9 +393,7 @@ open class ChatNewMessageViewModel : ViewModel() {
replyTo.value = null
multiOrchestrator = null
urlPreview = null
isUploadingImage = false
wantsInvoice = false
wantsZapraiser = false
@ -457,10 +423,6 @@ open class ChatNewMessageViewModel : ViewModel() {
}
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
this.multiOrchestrator?.remove(selected)
}
open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull()
private fun saveDraft() {
@ -575,24 +537,18 @@ open class ChatNewMessageViewModel : ViewModel() {
saveDraft()
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> = mutableStateMapOf(Pair(0, ""), Pair(1, ""))
fun canPost(): Boolean =
message.text.isNotBlank() &&
!isUploadingImage &&
uploadState?.isUploadingImage != true &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount != null) &&
(toUsers.text.isNotBlank()) &&
multiOrchestrator == null
uploadState?.multiOrchestrator == null
fun insertAtCursor(newElement: String) {
message = message.insertUrlAtCursor(newElement)
}
fun selectImage(uris: ImmutableList<SelectedMedia>) {
multiOrchestrator = MultiOrchestrator(uris)
}
override fun onCleared() {
super.onCleared()
Log.d("Init", "OnCleared: ${this.javaClass.simpleName}")

View File

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.webkit.MimeTypeMap
import androidx.compose.runtime.getValue

View File

@ -0,0 +1,253 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff
import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialog
import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder
import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier
import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
@Composable
fun PrivateMessageEditFieldRow(
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
nav: INav,
) {
channelScreenModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { channelScreenModel.replyTo.value = null } }
LaunchedEffect(key1 = channelScreenModel.draftTag) {
launch(Dispatchers.IO) {
channelScreenModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
channelScreenModel.sendDraft()
}
}
}
channelScreenModel.uploadState?.let { uploading ->
uploading.multiOrchestrator?.let { selectedFiles ->
val context = LocalContext.current
ChatFileUploadDialog(
room = channelScreenModel.room!!,
state = uploading,
upload = {
channelScreenModel.upload(
onError = accountViewModel::toast,
context = context,
onceUploaded = onSendNewMessage,
)
if (uploading.selectedServer.type != ServerType.NIP95) {
accountViewModel.account.settings.changeDefaultFileServer(uploading.selectedServer)
}
},
onCancel = uploading::reset,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
Column(
modifier = EditFieldModifier,
) {
ShowUserSuggestionList(
channelScreenModel.userSuggestions.userSuggestions,
channelScreenModel::autocompleteWithUser,
accountViewModel,
)
ShowEmojiSuggestionList(
channelScreenModel.emojiSuggestions,
channelScreenModel::autocompleteWithEmoji,
channelScreenModel::autocompleteWithEmojiUrl,
accountViewModel,
)
ThinPaddingTextField(
value = channelScreenModel.message,
onValueChange = { channelScreenModel.updateMessage(it) },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
shape = EditFieldBorder,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringRes(R.string.reply_here),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
ThinSendButton(
isActive =
channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
modifier = EditFieldTrailingIconModifier,
) {
channelScreenModel.sendPost(onSendNewMessage)
}
},
leadingIcon = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 6.dp),
) {
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier =
Modifier
.size(30.dp)
.padding(start = 2.dp),
onImageChosen = channelScreenModel::pickedMedia,
)
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
if (wantsToActivateNIP17) {
NewFeatureNIP17AlertDialog(
accountViewModel = accountViewModel,
onConfirm = { channelScreenModel.toggleNIP04And24() },
onDismiss = { wantsToActivateNIP17 = false },
)
}
IconButton(
modifier = Size30Modifier,
onClick = {
if (
!accountViewModel.account.settings.hideNIP17WarningDialog &&
!channelScreenModel.nip17 &&
!channelScreenModel.requiresNIP17
) {
wantsToActivateNIP17 = true
} else {
channelScreenModel.toggleNIP04And24()
}
},
) {
if (channelScreenModel.nip17) {
IncognitoIconOn(
modifier =
Modifier
.padding(top = 2.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
} else {
IncognitoIconOff(
modifier =
Modifier
.padding(top = 2.dp)
.size(18.dp),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
},
colors =
TextFieldDefaults.colors(
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary),
)
}
}
@Composable
fun NewFeatureNIP17AlertDialog(
accountViewModel: AccountViewModel,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
QuickActionAlertDialog(
title = stringRes(R.string.new_feature_nip17_might_not_be_available_title),
textContent = stringRes(R.string.new_feature_nip17_might_not_be_available_description),
buttonIconResource = R.drawable.incognito,
buttonText = stringRes(R.string.new_feature_nip17_activate),
onClickDoOnce = {
scope.launch { onConfirm() }
onDismiss()
},
onClickDontShowAgain = {
scope.launch {
onConfirm()
accountViewModel.account.settings.setHideNIP17WarningDialog()
}
onDismiss()
},
onDismiss = onDismiss,
)
}

View File

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf

View File

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -51,10 +51,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@ -72,28 +70,29 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SettingSwitchItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameOnlyDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatFileUploadView(
postViewModel: ChatFileUploadModel,
onClose: () -> Unit,
fun ChatFileUploadDialog(
room: ChatroomKey,
state: ChatFileUploadState,
upload: () -> Unit,
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
) {
val account = accountViewModel.account
val context = LocalContext.current
val scrollState = rememberScrollState()
Dialog(
onDismissRequest = { onClose() },
onDismissRequest = { onCancel() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
@ -108,34 +107,22 @@ fun ChatFileUploadView(
scrollBehavior = rememberHeightDecreaser(),
modifier = Modifier,
title = {
val room = postViewModel.chatroom
if (room == null) {
Text(
text = stringRes(R.string.dm_upload),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
NonClickableUserPictures(
room = room,
accountViewModel = accountViewModel,
size = Size34dp,
)
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Normal, accountViewModel)
}
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp), FontWeight.Normal, accountViewModel)
}
},
navigationIcon = {
IconButton(
modifier = TitleIconModifier,
onClick = {
postViewModel.cancelModel()
onClose()
state.reset()
onCancel()
},
) {
ArrowBackIcon()
@ -144,20 +131,8 @@ fun ChatFileUploadView(
actions = {
SendButton(
modifier = Modifier.padding(end = 5.dp),
onPost = {
postViewModel.upload(
onError = accountViewModel::toast,
context = context,
onceUploaded = onClose,
)
postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) {
account.settings.changeDefaultFileServer(it)
}
}
},
isActive = postViewModel.canPost(),
onPost = upload,
isActive = state.canPost(),
)
},
colors =
@ -176,7 +151,7 @@ fun ChatFileUploadView(
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(postViewModel, accountViewModel)
ImageVideoPostChat(state, accountViewModel)
}
}
}
@ -186,7 +161,7 @@ fun ChatFileUploadView(
@Composable
private fun ImageVideoPostChat(
postViewModel: ChatFileUploadModel,
fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel,
) {
val fileServers by accountViewModel.account.liveServerList.collectAsState()
@ -203,10 +178,10 @@ private fun ImageVideoPostChat(
}.toImmutableList()
}
postViewModel.multiOrchestrator?.let {
fileUploadState.multiOrchestrator?.let {
ShowImageUploadGallery(
it,
postViewModel::deleteMediaToUpload,
fileUploadState::deleteMediaToUpload,
accountViewModel,
)
}
@ -215,8 +190,8 @@ private fun ImageVideoPostChat(
label = { Text(text = stringRes(R.string.content_description)) },
modifier = Modifier.fillMaxWidth().padding(top = 3.dp).height(150.dp),
maxLines = 10,
value = postViewModel.caption,
onValueChange = { postViewModel.caption = it },
value = fileUploadState.caption,
onValueChange = { fileUploadState.caption = it },
placeholder = {
Text(
text = stringRes(R.string.content_description_example),
@ -233,8 +208,8 @@ private fun ImageVideoPostChat(
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = postViewModel.sensitiveContent,
onCheckedChange = { postViewModel.sensitiveContent = it },
checked = fileUploadState.sensitiveContent,
onCheckedChange = { fileUploadState.sensitiveContent = it },
)
SettingsRow(R.string.file_server, R.string.file_server_description) {
@ -246,7 +221,7 @@ private fun ImageVideoPostChat(
?.name
?: fileServers[0].name,
options = fileServerOptions,
onSelect = { postViewModel.selectedServer = fileServers[it] },
onSelect = { fileUploadState.selectedServer = fileServers[it] },
)
}
@ -272,7 +247,7 @@ private fun ImageVideoPostChat(
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text =
when (postViewModel.mediaQualitySlider) {
when (fileUploadState.mediaQualitySlider) {
0 -> stringRes(R.string.media_compression_quality_low)
1 -> stringRes(R.string.media_compression_quality_medium)
2 -> stringRes(R.string.media_compression_quality_high)
@ -284,8 +259,8 @@ private fun ImageVideoPostChat(
}
Slider(
value = postViewModel.mediaQualitySlider.toFloat(),
onValueChange = { postViewModel.mediaQualitySlider = it.toInt() },
value = fileUploadState.mediaQualitySlider.toFloat(),
onValueChange = { fileUploadState.mediaQualitySlider = it.toInt() },
valueRange = 0f..3f,
steps = 2,
)

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import kotlinx.collections.immutable.ImmutableList
@Stable
class ChatFileUploadState(
val defaultServer: ServerName,
) {
var isUploadingImage by mutableStateOf(false)
var selectedServer by mutableStateOf(defaultServer)
var caption by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
fun load(uris: ImmutableList<SelectedMedia>) {
reset()
this.multiOrchestrator = MultiOrchestrator(uris)
}
fun isImage(
url: String,
mimeType: String?,
): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url)
fun reset() {
multiOrchestrator = null
isUploadingImage = false
caption = ""
selectedServer = defaultServer
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
multiOrchestrator?.remove(selected)
}
fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null
fun hasPickedMedia() = multiOrchestrator != null
}

View File

@ -18,110 +18,52 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
open class ChatFileUploadModel : ViewModel() {
var account: Account? = null
var chatroom: ChatroomKey? = null
var isNip17: Boolean = false
var isUploadingImage by mutableStateOf(false)
var selectedServer by mutableStateOf<ServerName?>(null)
var caption by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(null)
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
open fun load(
uris: ImmutableList<SelectedMedia>,
chatroom: ChatroomKey,
isNip17: Boolean,
account: Account,
) {
this.chatroom = chatroom
this.caption = ""
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.isNip17 = isNip17
}
fun isImage(
url: String,
mimeType: String?,
): Boolean = mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(url)
fun upload(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
if (isNip17) {
uploadNIP17(onError, context, onceUploaded)
} else {
uploadNIP04(onError, context, onceUploaded)
}
}
class ChatFileUploader(
val chatroom: ChatroomKey,
val account: Account,
) {
fun uploadNIP17(
viewState: ChatFileUploadState,
scope: CoroutineScope,
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
val myAccount = account ?: return
val mySelectedServer = selectedServer ?: return
val myChatroom = chatroom ?: return
val myMultiOrchestrator = multiOrchestrator ?: return
val orchestrator = viewState.multiOrchestrator ?: return
viewModelScope.launch(Dispatchers.Default) {
isUploadingImage = true
scope.launch(Dispatchers.Default) {
viewState.isUploadingImage = true
val cipher = AESGCM()
val results =
myMultiOrchestrator.uploadEncrypted(
viewModelScope,
caption,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
orchestrator.uploadEncrypted(
scope,
viewState.caption,
if (viewState.sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
cipher,
mySelectedServer,
myAccount,
viewState.selectedServer,
account,
context,
)
@ -131,7 +73,7 @@ open class ChatFileUploadModel : ViewModel() {
val template =
ChatMessageEncryptedFileHeaderEvent.build(
url = state.result.url,
to = myChatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() },
to = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() },
cipher = cipher,
mimeType = state.result.mimeTypeBeforeEncryption,
originalHash = state.result.hashBeforeEncryption,
@ -142,54 +84,51 @@ open class ChatFileUploadModel : ViewModel() {
state.result.fileHeader.blurHash
?.blurhash,
) {
if (caption.isNotEmpty()) {
alt(caption)
if (viewState.caption.isNotEmpty()) {
alt(viewState.caption)
}
if (sensitiveContent) {
if (viewState.sensitiveContent) {
contentWarning("")
}
}
account?.sendNIP17EncryptedFile(template)
account.sendNIP17EncryptedFile(template)
}
}
onceUploaded()
cancelModel()
viewState.reset()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
viewState.isUploadingImage = false
}
}
fun uploadNIP04(
viewState: ChatFileUploadState,
scope: CoroutineScope,
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
viewModelScope.launch(Dispatchers.Default) {
val myAccount = account ?: return@launch
val orchestrator = viewState.multiOrchestrator ?: return
val myMultiOrchestrator = multiOrchestrator ?: return@launch
val mySelectedServer = selectedServer ?: return@launch
val myChatroom = chatroom ?: return@launch
isUploadingImage = true
scope.launch(Dispatchers.Default) {
viewState.isUploadingImage = true
val results =
myMultiOrchestrator.upload(
viewModelScope,
caption,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
mySelectedServer,
myAccount,
orchestrator.upload(
scope,
viewState.caption,
if (viewState.sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
viewState.selectedServer,
account,
context,
)
@ -197,11 +136,11 @@ open class ChatFileUploadModel : ViewModel() {
results.successful.forEach {
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMetaAttachments = IMetaAttachments()
iMetaAttachments.add(it.result, caption, if (sensitiveContent) "" else null)
iMetaAttachments.add(it.result, viewState.caption, if (viewState.sensitiveContent) "" else null)
myAccount.sendPrivateMessage(
account.sendPrivateMessage(
message = it.result.url,
toUser = myChatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() },
toUser = chatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() },
replyingTo = null,
zapReceiver = null,
contentWarningReason = null,
@ -214,7 +153,7 @@ open class ChatFileUploadModel : ViewModel() {
}
onceUploaded()
cancelModel()
viewState.reset()
}
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
@ -222,22 +161,7 @@ open class ChatFileUploadModel : ViewModel() {
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
viewState.isUploadingImage = false
}
}
open fun cancelModel() {
multiOrchestrator = null
isUploadingImage = false
caption = ""
selectedServer = defaultServer()
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
multiOrchestrator?.remove(selected)
}
fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null && selectedServer != null
fun defaultServer() = account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0]
}

View File

@ -120,9 +120,9 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes

View File

@ -39,7 +39,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.messages.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
@Composable
fun ThinSendButton(
isActive: Boolean,
modifier: Modifier,
onClick: () -> Unit,
) {
IconButton(
enabled = isActive,
modifier = modifier,
onClick = onClick,
) {
Icon(
imageVector = Icons.Default.Send,
contentDescription = stringRes(id = R.string.accessibility_send),
modifier = Size20Modifier,
)
}
}

View File

@ -147,8 +147,8 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.private.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness