diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a6931869c..36f52680a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -2577,7 +2577,6 @@ class Account( message: String, toUser: User, replyingTo: Note? = null, - mentions: List?, zapReceiver: List? = null, contentWarningReason: String? = null, zapRaiserAmount: Long? = null, @@ -2590,7 +2589,6 @@ class Account( message, toUser.toPTag(), replyingTo, - mentions, zapReceiver, contentWarningReason, zapRaiserAmount, @@ -2605,7 +2603,6 @@ class Account( message: String, toUser: PTag, replyingTo: Note? = null, - mentions: List?, zapReceiver: List? = null, contentWarningReason: String? = null, zapRaiserAmount: Long? = null, @@ -3183,10 +3180,16 @@ class Account( fun cachedDecryptContent(event: Event?): String? { if (event == null) return null - return if (event is PrivateDmEvent && isWriteable()) { - event.cachedContentFor(signer) - } else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { - event.cachedPrivateZap()?.content + return if (isWriteable()) { + if (event is PrivateDmEvent) { + event.cachedContentFor(signer) + } else if (event is LnZapRequestEvent && event.isPrivateZap()) { + event.cachedPrivateZap()?.content + } else if (event is DraftEvent) { + event.preCachedDraft(signer)?.content + } else { + event.content + } } else { event.content } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt index d43d32d70..712a7f4f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -236,16 +236,13 @@ class User( }.flatten() @Synchronized - private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { - checkNotInMainThread() - - return privateChatrooms[key] + private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom = + privateChatrooms[key] ?: run { val privateChatroom = Chatroom() privateChatrooms = privateChatrooms + Pair(key, privateChatroom) privateChatroom } - } private fun getOrCreatePrivateChatroom(user: User): Chatroom { val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index b378aa62f..2ec4fb18d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.ChannelName import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index b77193530..bf097b510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -86,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject @@ -818,7 +819,6 @@ open class NewPostViewModel : ViewModel() { message = tagger.message, toUser = originalNote!!.author!!, replyingTo = originalNote!!, - mentions = tagger.pTags, zapReceiver = zapReceiver, contentWarningReason = contentWarningReason, zapRaiserAmount = localZapRaiserAmount, @@ -827,7 +827,7 @@ open class NewPostViewModel : ViewModel() { draftTag = localDraft, ) } else if (originalNote?.event is NIP17Group) { - val replyHint = originalNote?.toEventHint() + val replyHint = originalNote?.toEventHint() val template = if (replyHint == null) { @@ -868,7 +868,7 @@ open class NewPostViewModel : ViewModel() { account?.sendNIP17PrivateMessage(template, localDraft) } else if (!dmUsers.isNullOrEmpty()) { if (nip17 || dmUsers.size > 1) { - val replyHint = originalNote?.toEventHint() + val replyHint = originalNote?.toEventHint() val template = if (replyHint == null) { ChatMessageEvent.build(tagger.message, dmUsers.map { it.toPTag() }) { @@ -910,7 +910,6 @@ open class NewPostViewModel : ViewModel() { message = tagger.message, toUser = dmUsers.first(), replyingTo = originalNote, - mentions = tagger.pTags, contentWarningReason = contentWarningReason, zapReceiver = zapReceiver, zapRaiserAmount = localZapRaiserAmount, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt index fcfc24366..0d6cfd01f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/RelayInformationDialog.kt @@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 5d167d910..3c957c7df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -91,7 +91,7 @@ import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index dccb3de4a..171361ea4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NewItemsBubble +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.NewItemsBubble import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 9e9582a49..8c7604143 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -60,10 +60,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.chatrooms.ChannelScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreenByAuthor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.ChatroomListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomScreenByAuthor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen @@ -94,6 +94,16 @@ fun NavBackStackEntry.message(): String? = URLDecoder.decode(it, "utf-8") } +fun NavBackStackEntry.replyId(): String? = + arguments?.getString("replyId")?.let { + URLDecoder.decode(it, "utf-8") + } + +fun NavBackStackEntry.draftId(): String? = + arguments?.getString("draftId")?.let { + URLDecoder.decode(it, "utf-8") + } + @Composable fun AppNavigation( accountViewModel: AccountViewModel, @@ -219,6 +229,8 @@ fun AppNavigation( ChatroomScreen( roomId = it.id(), draftMessage = it.message(), + replyToNote = it.replyId(), + editFromDraft = it.draftId(), accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt index aa04022e2..a5daf3ab6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/INav.kt @@ -54,6 +54,8 @@ interface INav { fun nav(route: String) + fun nav(computeRoute: suspend () -> String) + fun newStack(route: String) fun popBack() @@ -91,6 +93,15 @@ class Nav( } } + override fun nav(computeRoute: suspend () -> String) { + scope.launch { + val route = computeRoute() + if (getRouteWithArguments(controller) != route) { + controller.navigate(route) + } + } + } + override fun newStack(route: String) { scope.launch { controller.navigate(route) { @@ -135,6 +146,9 @@ object EmptyNav : INav { override fun nav(route: String) { } + override fun nav(computeRoute: suspend () -> String) { + } + override fun newStack(route: String) { } @@ -161,6 +175,11 @@ class ObservableNavigate( nav.nav(route) } + override fun nav(computeRoute: suspend () -> String) { + onNavigate() + nav.nav(computeRoute) + } + override fun newStack(route: String) { onNavigate() nav.newStack(route) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt index d8c678e96..a30c8a3bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/RouteMaker.kt @@ -21,8 +21,10 @@ package com.vitorpamplona.amethyst.ui.navigation import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.LocalCache.users import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -36,7 +38,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent -import kotlinx.collections.immutable.persistentSetOf import java.net.URLEncoder fun routeFor( @@ -110,23 +111,71 @@ fun routeFor( fun routeToMessage( user: HexKey, draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, + accountViewModel: AccountViewModel, +): String = + routeToMessage( + setOf(user), + draftMessage, + replyId, + quoteId, + accountViewModel, + ) + +fun routeToMessage( + users: Set, + draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, + accountViewModel: AccountViewModel, +) = routeToMessage( + ChatroomKey(users), + draftMessage, + replyId, + quoteId, + accountViewModel, +) + +fun routeToMessage( + room: ChatroomKey, + draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, accountViewModel: AccountViewModel, ): String { - val withKey = ChatroomKey(persistentSetOf(user)) - accountViewModel.account.userProfile().createChatroom(withKey) - return if (draftMessage != null) { - val encodedMessage = URLEncoder.encode(draftMessage, "utf-8") - "Room/${withKey.hashCode()}?message=$encodedMessage" - } else { - "Room/${withKey.hashCode()}" + accountViewModel.account.userProfile().createChatroom(room) + + val params = + listOfNotNull( + draftMessage?.let { + "message=${URLEncoder.encode(it, "utf-8")}" + }, + replyId?.let { + "replyId=${URLEncoder.encode(it, "utf-8")}" + }, + quoteId?.let { + "quoteId=${URLEncoder.encode(it, "utf-8")}" + }, + ) + + return buildString { + append("Room/") + append(room.hashCode().toString()) + if (params.isNotEmpty()) { + append("?") + append(params.joinToString("&")) + } } } fun routeToMessage( user: User, draftMessage: String?, + replyId: HexKey? = null, + quoteId: HexKey? = null, accountViewModel: AccountViewModel, -): String = routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) +): String = routeToMessage(user.pubkeyHex, draftMessage, replyId, quoteId, accountViewModel) fun routeFor(note: Channel): String = "Channel/${note.idHex}" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 5f542f652..f3900a5e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -169,7 +169,7 @@ sealed class Route( object Room : Route( - route = "Room/{id}?message={message}", + route = "Room/{id}?message={message}&replyId={replyId}&draftId={draftId}", icon = R.drawable.ic_moments, arguments = listOf( @@ -179,6 +179,16 @@ sealed class Route( nullable = true defaultValue = null }, + navArgument("replyId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument("draftId") { + type = NavType.StringType + nullable = true + defaultValue = null + }, ).toImmutableList(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 66feb26ad..1597d6fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -78,11 +78,11 @@ import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.EndedFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.OfflineFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.EndedFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.OfflineFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt index b49180a09..126117a98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/Loaders.kt @@ -25,7 +25,6 @@ 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.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext @@ -73,15 +72,15 @@ fun LoadDecryptedContentOrNull( accountViewModel: AccountViewModel, inner: @Composable (String?) -> Unit, ) { - @Suppress("ProduceStateDoesNotAssignValue") - val decryptedContent by - produceState(initialValue = accountViewModel.cachedDecrypt(note), key1 = note.event?.id) { - accountViewModel.decrypt(note) { - if (value != it) { - value = it - } + var decryptedContent by remember(note.event?.id) { mutableStateOf(accountViewModel.cachedDecrypt(note)) } + + LaunchedEffect(note.event?.id) { + accountViewModel.decrypt(note) { + if (decryptedContent != it) { + decryptedContent = it } } + } inner(decryptedContent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt index ab3e42772..789f052bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiUserMessageDialog.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -74,7 +73,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -202,15 +200,14 @@ fun ErrorRow( horizontalArrangement = Arrangement.SpaceBetween, ) { errorState.user?.let { - val scope = rememberCoroutineScope() Column(Modifier.width(Size40dp), horizontalAlignment = Alignment.Start) { UserPicture(errorState.user, Size30dp, Modifier, accountViewModel, nav) Spacer(StdVertSpacer) IconButton( modifier = Size30Modifier, onClick = { - scope.launch(Dispatchers.IO) { - nav.nav(routeToMessage(it, errorState.error, accountViewModel)) + nav.nav { + routeToMessage(it, errorState.error, accountViewModel = accountViewModel) } }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 1f099290e..e2169a1ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -123,12 +123,11 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.RenderChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.RenderChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp @@ -1022,7 +1021,7 @@ fun DisplayDraftChat() { Text( "Draft", color = MaterialTheme.colorScheme.placeholderText, - modifier = HalfEndPadding, + modifier = Modifier, fontWeight = FontWeight.Bold, fontSize = Font12SP, maxLines = 1, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index d919e88ae..6d8bb6f52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -627,7 +627,11 @@ fun ZapVote( title = toast.title, textContent = toast.msg, onClickStartMessage = { - baseNote.author?.let { nav.nav(routeToMessage(it, toast.msg, accountViewModel)) } + baseNote.author?.let { + nav.nav { + routeToMessage(it, toast.msg, accountViewModel = accountViewModel) + } + } }, onDismiss = { showErrorMessageDialog = null }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 7965c62c7..0be5e8c36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute +import com.vitorpamplona.amethyst.ui.navigation.routeToMessage import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -147,7 +148,9 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.reactionBox import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import kotlinx.collections.immutable.ImmutableList @@ -209,13 +212,16 @@ private fun InnerReactionRow( ) }, three = { - BoostWithDialog( - baseNote, - editState, - MaterialTheme.colorScheme.placeholderText, - accountViewModel, - nav, - ) + val isDM = baseNote.event is ChatroomKeyable + if (!isDM) { + BoostWithDialog( + baseNote, + editState, + MaterialTheme.colorScheme.placeholderText, + accountViewModel, + nav, + ) + } }, four = { LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) @@ -558,56 +564,46 @@ private fun BoostWithDialog( accountViewModel: AccountViewModel, nav: INav, ) { - var wantsToQuote by remember { mutableStateOf(null) } - var wantsToFork by remember { mutableStateOf(null) } - - if (wantsToQuote != null) { - val route = - buildNewPostRoute( - quote = wantsToQuote?.idHex, - version = - (editState.value as? GenericLoadable.Loaded) - ?.loaded - ?.modificationToShow - ?.value - ?.idHex, - ) - nav.nav(route) - } - - if (wantsToFork != null) { - val replyTo = - remember(wantsToFork) { - val forkEvent = wantsToFork?.event - if (forkEvent is BaseThreadedEvent) { - val hex = forkEvent.replyingTo() - wantsToFork?.replyTo?.filter { it.event?.id == hex }?.firstOrNull() - } else { - null - } - } - - val route = - buildNewPostRoute( - quote = wantsToQuote?.idHex, - baseReplyTo = replyTo?.idHex, - fork = wantsToFork?.idHex, - version = - (editState.value as? GenericLoadable.Loaded) - ?.loaded - ?.modificationToShow - ?.value - ?.idHex, - ) - nav.nav(route) - } - BoostReaction( baseNote, grayTint, accountViewModel, - onQuotePress = { wantsToQuote = baseNote }, - onForkPress = { wantsToFork = baseNote }, + onQuotePress = { + nav.nav { + buildNewPostRoute( + quote = baseNote.idHex, + version = + (editState.value as? GenericLoadable.Loaded) + ?.loaded + ?.modificationToShow + ?.value + ?.idHex, + ) + } + }, + onForkPress = { + nav.nav { + val forkEvent = baseNote.event + val replyTo = + if (forkEvent is BaseThreadedEvent) { + val hex = forkEvent.replyingTo() + baseNote.replyTo?.filter { it.event?.id == hex }?.firstOrNull() + } else { + null + } + + buildNewPostRoute( + baseReplyTo = replyTo?.idHex, + fork = baseNote.idHex, + version = + (editState.value as? GenericLoadable.Loaded) + ?.loaded + ?.modificationToShow + ?.value + ?.idHex, + ) + } + }, ) } @@ -618,18 +614,37 @@ private fun ReplyReactionWithDialog( accountViewModel: AccountViewModel, nav: INav, ) { - var wantsToReplyTo by remember { mutableStateOf(null) } - - if (wantsToReplyTo != null) { - val route = - buildNewPostRoute( - baseReplyTo = wantsToReplyTo?.idHex, - quote = null, - ) - nav.nav(route) + ReplyReaction(baseNote, grayTint, accountViewModel) { + val noteEvent = baseNote.event + if (noteEvent is PrivateDmEvent) { + nav.nav { + routeToMessage( + room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), + draftMessage = null, + replyId = noteEvent.id, + quoteId = null, + accountViewModel = accountViewModel, + ) + } + } else if (noteEvent is ChatroomKeyable) { + nav.nav { + routeToMessage( + room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex), + draftMessage = null, + replyId = noteEvent.id, + quoteId = null, + accountViewModel = accountViewModel, + ) + } + } else { + nav.nav { + buildNewPostRoute( + baseReplyTo = baseNote.idHex, + quote = null, + ) + } + } } - - ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 2a597c8c4..b9fa483c6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index b88b973bb..a7848c13b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt index 054b6b891..e0827a8b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt @@ -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.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt index fe6c06844..02461c72e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessageEncryptedFile.kt @@ -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.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -58,7 +58,6 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM -import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip31Alts.alt import kotlinx.collections.immutable.persistentListOf diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 9b27beb67..26e7b8d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -61,9 +61,9 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.JoinCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LeaveCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NormalTimeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.JoinCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LeaveCommunityButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 6efc13e20..6fe0fe0d0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -58,8 +58,8 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index 472d06f64..0b05a3b3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt index 6a0dd6bdb..f5fe85647 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/PrivateMessage.kt @@ -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.chatrooms.ChatroomHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9f9f6fbcf..1f3ab1d03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1666,6 +1666,8 @@ class AccountViewModel( } } + suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account) + fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow() val draftNoteCache = CachedDraftNotes(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt index 7b3d94684..aafb85a72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/NewPostScreen.kt @@ -162,7 +162,7 @@ import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChannelFabColumn.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelFabColumn.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChannelFabColumn.kt index 06e9802d8..ab302e42b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChannelFabColumn.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomHeaderCompose.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomHeaderCompose.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomHeaderCompose.kt index 391853bc1..45bca56e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomHeaderCompose.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomListFeedView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListFeedView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomListFeedView.kt index 5ee523f79..4cbffa36e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomListFeedView.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomListScreen.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomListScreen.kt index 785138e7b..43110e79f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/ChatroomListScreen.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box @@ -75,10 +75,13 @@ import com.vitorpamplona.amethyst.ui.navigation.MainTopBar 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.chatlist.private.Chatroom +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.Channel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -121,6 +124,7 @@ fun ChatroomListScreen( class TwoPaneNav( val nav: INav, + val scope: CoroutineScope, ) : INav { override val drawerState: DrawerState = nav.drawerState @@ -134,6 +138,17 @@ class TwoPaneNav( } } + override fun nav(routeMaker: suspend () -> String) { + scope.launch(Dispatchers.Default) { + val route = routeMaker() + if (route.startsWith("Room/") || route.startsWith("Channel/")) { + innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/")) + } else { + nav.nav(route) + } + } + } + override fun newStack(route: String) { nav.newStack(route) } @@ -172,7 +187,8 @@ fun ChatroomListTwoPane( nav: INav, ) { /** The index of the currently selected word, or `null` if none is selected */ - val twoPaneNav = remember { TwoPaneNav(nav) } + val scope = rememberCoroutineScope() + val twoPaneNav = remember { TwoPaneNav(nav, scope) } val strategy = remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatBubbleLayout.kt new file mode 100644 index 000000000..9b1cfcaab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatBubbleLayout.kt @@ -0,0 +1,287 @@ +/** + * 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.chatlist.private + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier +import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe +import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem +import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier +import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier +import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding +import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.chatBackground +import com.vitorpamplona.amethyst.ui.theme.chatDraftBackground +import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink +import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits + +@Preview +@Composable +private fun BubblePreview() { + val backgroundBubbleColor = + remember { + mutableStateOf(Color.Transparent) + } + + Column { + ChatBubbleLayout( + isLoggedInUser = false, + isDraft = false, + innerQuote = false, + isComplete = true, + hasDetailsToShow = true, + drawAuthorInfo = true, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape) + .background(Color.LightGray), + ) + }, + name = { + Text("Someone else", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("This is my note") + } + + ChatBubbleLayout( + isLoggedInUser = true, + isDraft = true, + innerQuote = false, + isComplete = true, + hasDetailsToShow = true, + drawAuthorInfo = true, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape), + ) + }, + name = { + Text("Me", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("This is a very long long loong note") + } + + ChatBubbleLayout( + isLoggedInUser = true, + isDraft = false, + innerQuote = false, + isComplete = false, + hasDetailsToShow = false, + drawAuthorInfo = false, + parentBackgroundColor = backgroundBubbleColor, + onClick = { false }, + onAuthorClick = {}, + actionMenu = { onDismiss -> + }, + drawAuthorLine = { + UserDisplayNameLayout( + picture = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = + Modifier + .size(Size20dp) + .clip(CircleShape), + ) + }, + name = { + Text("Me", fontWeight = FontWeight.Bold) + }, + ) + }, + detailRow = { Text("Relays and Actions") }, + ) { backgroundBubbleColor -> + Text("Short note") + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatBubbleLayout( + isLoggedInUser: Boolean, + isDraft: Boolean, + innerQuote: Boolean, + isComplete: Boolean, + hasDetailsToShow: Boolean, + drawAuthorInfo: Boolean, + parentBackgroundColor: MutableState? = null, + onClick: () -> Boolean, + onAuthorClick: () -> Unit, + actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, + detailRow: @Composable () -> Unit, + drawAuthorLine: @Composable () -> Unit, + inner: @Composable (MutableState) -> 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 + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatFileUploadModel.kt similarity index 70% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatFileUploadModel.kt index 344e39f11..e717bc5c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatFileUploadModel.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private import android.content.Context import androidx.compose.runtime.Stable @@ -40,9 +40,11 @@ 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.stringRes +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash 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.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import kotlinx.collections.immutable.ImmutableList @@ -53,6 +55,7 @@ import kotlinx.coroutines.launch open class ChatFileUploadModel : ViewModel() { var account: Account? = null var chatroom: ChatroomKey? = null + var isNip17: Boolean = false var isUploadingImage by mutableStateOf(false) @@ -69,6 +72,7 @@ open class ChatFileUploadModel : ViewModel() { open fun load( uris: ImmutableList, chatroom: ChatroomKey, + isNip17: Boolean, account: Account, ) { this.chatroom = chatroom @@ -76,6 +80,7 @@ open class ChatFileUploadModel : ViewModel() { this.account = account this.multiOrchestrator = MultiOrchestrator(uris) this.selectedServer = defaultServer() + this.isNip17 = isNip17 } fun isImage( @@ -87,6 +92,18 @@ open class ChatFileUploadModel : ViewModel() { onError: (title: String, message: String) -> Unit, context: Context, onceUploaded: () -> Unit, + ) { + if (isNip17) { + uploadNIP17(onError, context, onceUploaded) + } else { + uploadNIP04(onError, context, onceUploaded) + } + } + + fun uploadNIP17( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, ) { val myAccount = account ?: return val mySelectedServer = selectedServer ?: return @@ -152,6 +169,65 @@ open class ChatFileUploadModel : ViewModel() { } } + fun uploadNIP04( + onError: (title: String, message: String) -> Unit, + context: Context, + onceUploaded: () -> Unit, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myAccount = account ?: return@launch + + val myMultiOrchestrator = multiOrchestrator ?: return@launch + + val mySelectedServer = selectedServer ?: return@launch + val myChatroom = chatroom ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + viewModelScope, + caption, + if (sensitiveContent) "" else null, + MediaCompressor.intToCompressorQuality(mediaQualitySlider), + mySelectedServer, + myAccount, + context, + ) + + if (results.allGood) { + results.successful.forEach { + if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + val iMetaAttachments = IMetaAttachments() + iMetaAttachments.add(it.result, caption, if (sensitiveContent) "" else null) + + myAccount.sendPrivateMessage( + message = it.result.url, + toUser = myChatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }, + replyingTo = null, + zapReceiver = null, + contentWarningReason = null, + zapRaiserAmount = null, + geohash = null, + imetas = iMetaAttachments.iMetaAttachments, + emojis = null, + draftTag = null, + ) + } + + onceUploaded() + cancelModel() + } + } 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 cancelModel() { multiOrchestrator = null isUploadingImage = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatFileUploadView.kt similarity index 98% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatFileUploadView.kt index 445a261fe..93a9cf76c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatFileUploadView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatFileUploadView.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -148,9 +148,8 @@ fun ChatFileUploadView( postViewModel.upload( onError = accountViewModel::toast, context = context, - ) { - onClose - } + onceUploaded = onClose, + ) postViewModel.selectedServer?.let { if (it.type != ServerType.NIP95) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatNewMessageViewModel.kt new file mode 100644 index 000000000..a6cc1341e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatNewMessageViewModel.kt @@ -0,0 +1,640 @@ +/** + * 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.chatlist.private + +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 +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.model.Account +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.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.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip14Subject.subject +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis +import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitive +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip92IMeta.imetas +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID + +@Stable +open class ChatNewMessageViewModel : ViewModel() { + var draftTag: String by mutableStateOf(UUID.randomUUID().toString()) + + var accountViewModel: AccountViewModel? = null + var account: Account? = null + var room: ChatroomKey? = null + + var requiresNIP17: Boolean = false + + val replyTo = mutableStateOf(null) + + val iMetaAttachments = IMetaAttachments() + + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + + val userSuggestions = UserSuggestions() + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + val emojiSearch: MutableStateFlow = MutableStateFlow("") + val emojiSuggestions: StateFlow> by lazy { + account!! + .myEmojis + .combine(emojiSearch) { list, search -> + if (search.length == 1) { + list + } else if (search.isNotEmpty()) { + val code = search.removePrefix(":") + list.filter { it.code.startsWith(code) } + } else { + emptyList() + } + }.flowOn(Dispatchers.Default) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList(), + ) + } + + var toUsers by mutableStateOf(TextFieldValue("")) + var subject by mutableStateOf(TextFieldValue("")) + + // Images and Videos + var multiOrchestrator by mutableStateOf(null) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf>(Split()) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + var zapRaiserAmount by mutableStateOf(null) + + // NIP17 Wrapped DMs / Group messages + var nip17 by mutableStateOf(false) + + val draftTextChanges = Channel(Channel.CONFLATED) + + fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress() + + fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null + + fun user(): User? = account?.userProfile() + + 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 + } + + open fun load(room: ChatroomKey) { + this.room = room + this.requiresNIP17 = room.users.size > 1 + if (this.requiresNIP17) { + this.nip17 = true + } + } + + open fun reply(replyNote: Note) { + replyTo.value = replyNote + } + + open fun quote(quote: Note) { + message = TextFieldValue(message.text + "\nnostr:${quote.toNEvent()}") + urlPreview = findUrlInMessage() + + // creates a split with that author. + val accountViewModel = accountViewModel ?: return + val quotedAuthor = quote.author ?: return + + if (quotedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { + if (forwardZapTo.items.none { it.key.pubkeyHex == quotedAuthor.pubkeyHex }) { + forwardZapTo.addItem(quotedAuthor) + } + if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) { + forwardZapTo.addItem(accountViewModel.userProfile()) + } + + val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedAuthor.pubkeyHex } + forwardZapTo.updatePercentage(pos, 0.9f) + + wantsForwardZapTo = true + } + } + + open fun editFromDraft(draft: Note) { + val noteEvent = draft.event + val noteAuthor = draft.author + + if (noteEvent is DraftEvent && noteAuthor != null) { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel?.createTempDraftNote(noteEvent) { innerNote -> + if (innerNote != null) { + val oldTag = (draft.event as? AddressableEvent)?.dTag() + if (oldTag != null) { + draftTag = oldTag + } + loadFromDraft(innerNote) + } + } + } + } + } + + private fun loadFromDraft(draft: Note) { + Log.d("draft", draft.event!!.toJson()) + + val draftEvent = draft.event ?: return + val accountViewModel = accountViewModel ?: return + + val localfowardZapTo = draftEvent.tags.zapSplitSetup() + val totalWeight = localfowardZapTo.sumOf { it.weight } + forwardZapTo = Split() + localfowardZapTo.forEach { + if (it is ZapSplitSetup) { + val user = LocalCache.getOrCreateUser(it.pubKeyHex) + forwardZapTo.addItem(user, (it.weight / totalWeight).toFloat()) + } + // don't support edditing old-style splits. + } + forwardZapToEditting = TextFieldValue("") + wantsForwardZapTo = localfowardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount = null + if (zapraiser != null) { + zapRaiserAmount = zapraiser + } + + if (forwardZapTo.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + draftEvent.subject()?.let { + subject = TextFieldValue() + } + + if (draftEvent is NIP17Group) { + toUsers = + TextFieldValue( + draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, + ) + } else if (draftEvent is PrivateDmEvent) { + val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } + toUsers = TextFieldValue("@$recepientNpub") + } + + message = + if (draftEvent is PrivateDmEvent) { + TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "") + } else { + TextFieldValue(draftEvent.content) + } + + requiresNIP17 = draftEvent is NIP17Group + nip17 = draftEvent is NIP17Group + + urlPreview = findUrlInMessage() + } + + fun sendPost() { + viewModelScope.launch(Dispatchers.IO) { + innerSendPost(null) + accountViewModel?.deleteDraft(draftTag) + cancel() + } + } + + suspend fun sendPostSync() { + innerSendPost(null) + accountViewModel?.deleteDraft(draftTag) + cancel() + } + + fun sendDraft() { + viewModelScope.launch(Dispatchers.IO) { + sendDraftSync() + } + } + + suspend fun sendDraftSync() { + if (message.text.isBlank()) { + account?.deleteDraft(draftTag) + } else { + innerSendPost(draftTag) + } + } + + private fun innerSendPost(dTag: String?) { + val room = room ?: return + val accountViewModel = accountViewModel ?: return + + val urls = findURLs(message.text) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value) + + val message = message.text + + if (nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { + val replyHint = replyTo.value?.toEventHint() + + val template = + if (replyHint == null) { + ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrUris(message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChatMessageEvent.reply(message, replyHint) { + hashtags(findHashtags(message)) + references(findURLs(message)) + quotes(findNostrUris(message)) + + emojis(emojis) + imetas(usedAttachments) + } + } + + accountViewModel.account.sendNIP17PrivateMessage(template, dTag) + } else { + accountViewModel.account.sendPrivateMessage( + message = message, + toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }, + replyingTo = replyTo.value, + contentWarningReason = null, + imetas = usedAttachments, + draftTag = dTag, + ) + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } + } + } + + 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("") + subject = TextFieldValue("") + + replyTo.value = null + + multiOrchestrator = null + urlPreview = null + isUploadingImage = false + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + + forwardZapTo = Split() + forwardZapToEditting = TextFieldValue("") + + userSuggestions.reset() + userSuggestionsMainMessage = null + + if (emojiSearch.value.isNotEmpty()) { + emojiSearch.tryEmit("") + } + + draftTag = UUID.randomUUID().toString() + + NostrSearchEventOrUserDataSource.clear() + } + + fun deleteDraft() { + viewModelScope.launch(Dispatchers.IO) { + accountViewModel?.deleteDraft(draftTag) + } + } + + fun deleteMediaToUpload(selected: SelectedMediaProcessing) { + this.multiOrchestrator?.remove(selected) + } + + open fun findUrlInMessage(): String? = RichTextParser().parseValidUrls(message.text).firstOrNull() + + private fun saveDraft() { + draftTextChanges.trySend("") + } + + open fun addToMessage(it: String) { + updateMessage(TextFieldValue(message.text + " " + it)) + } + + open fun updateMessage(newMessage: TextFieldValue) { + message = newMessage + urlPreview = findUrlInMessage() + + if (newMessage.selection.collapsed) { + val lastWord = newMessage.currentWord() + + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + + accountViewModel?.let { + userSuggestions.processCurrentWord(lastWord, it) + } + + if (lastWord.startsWith(":")) { + emojiSearch.tryEmit(lastWord) + } else { + if (emojiSearch.value.isNotBlank()) { + emojiSearch.tryEmit("") + } + } + } + + saveDraft() + } + + open fun updateToUsers(newToUsersValue: TextFieldValue) { + toUsers = newToUsersValue + + if (newToUsersValue.selection.collapsed) { + val lastWord = newToUsersValue.currentWord() + userSuggestionsMainMessage = UserSuggestionAnchor.TO_USERS + + accountViewModel?.let { + userSuggestions.processCurrentWord(lastWord, it) + } + } + saveDraft() + } + + open fun updateSubject(it: TextFieldValue) { + subject = it + saveDraft() + } + + open fun updateZapForwardTo(newZapForwardTo: TextFieldValue) { + forwardZapToEditting = newZapForwardTo + if (newZapForwardTo.selection.collapsed) { + val lastWord = newZapForwardTo.text + userSuggestionsMainMessage = UserSuggestionAnchor.FORWARD_ZAPS + accountViewModel?.let { + userSuggestions.processCurrentWord(lastWord, it) + } + } + } + + open fun autocompleteWithUser(item: User) { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = message.currentWord() + message = userSuggestions.replaceCurrentWord(message, lastWord, item) + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.FORWARD_ZAPS) { + forwardZapTo.addItem(item) + forwardZapToEditting = TextFieldValue("") + } else if (userSuggestionsMainMessage == UserSuggestionAnchor.TO_USERS) { + val lastWord = toUsers.currentWord() + toUsers = userSuggestions.replaceCurrentWord(toUsers, lastWord, item) + + val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(item.pubkeyHex))?.event as? AdvertisedRelayListEvent)?.readRelays() + nip17 = relayList != null + } + + userSuggestionsMainMessage = null + userSuggestions.reset() + + saveDraft() + } + + open fun autocompleteWithEmoji(item: Account.EmojiMedia) { + val wordToInsert = ":${item.code}:" + message = message.replaceCurrentWord(wordToInsert) + + emojiSearch.tryEmit("") + + saveDraft() + } + + open fun autocompleteWithEmojiUrl(item: Account.EmojiMedia) { + val wordToInsert = item.url.url + " " + + viewModelScope.launch(Dispatchers.IO) { + iMetaAttachments.downloadAndPrepare( + item.url.url, + accountViewModel?.account?.shouldUseTorForImageDownload() ?: false, + ) + } + + message = message.replaceCurrentWord(wordToInsert) + + emojiSearch.tryEmit("") + + urlPreview = findUrlInMessage() + + saveDraft() + } + + private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) + + fun canPost(): Boolean = + message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount != null) && + (toUsers.text.isNotBlank()) && + multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun selectImage(uris: ImmutableList) { + multiOrchestrator = MultiOrchestrator(uris) + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + fun toggleNIP04And24() { + if (requiresNIP17) { + nip17 = true + } else { + nip17 = !nip17 + } + if (message.text.isNotBlank()) { + saveDraft() + } + } + + fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.updatePercentage(index, sliderValue) + } + + fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.items.any { it.key == taggedUser }) { + forwardZapTo.addItem(taggedUser) + } + } + } + } + + fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount = newAmount + saveDraft() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + saveDraft() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomFeedView.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomFeedView.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomFeedView.kt index 11f64078f..cbc61b73f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomFeedView.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Row diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomMessageCompose.kt similarity index 67% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomMessageCompose.kt index 4421d1649..9b0a28d6a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomMessageCompose.kt @@ -18,25 +18,16 @@ * 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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Person import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -44,17 +35,13 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.Note @@ -80,26 +67,15 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort import com.vitorpamplona.amethyst.ui.note.types.RenderEncryptedFile import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier -import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe -import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem -import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier -import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP -import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding -import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size5Modifier import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox -import com.vitorpamplona.amethyst.ui.theme.chatBackground import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier -import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink -import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists @@ -188,6 +164,7 @@ fun NormalChatNote( ChatBubbleLayout( isLoggedInUser = isLoggedInUser, + isDraft = note.event is DraftEvent, innerQuote = innerQuote, isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE, hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), @@ -223,27 +200,29 @@ fun NormalChatNote( ) }, detailRow = { - if (note.isDraft()) { - DisplayDraftChat() - } IncognitoBadge(note) ChatTimeAgo(note) RelayBadgesHorizontal(note, accountViewModel, nav = nav) + Spacer(modifier = DoubleHorzSpacer) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) { - ReplyReaction( - baseNote = note, - grayTint = MaterialTheme.colorScheme.placeholderText, - accountViewModel = accountViewModel, - showCounter = false, - iconSizeModifier = Size18Modifier, - ) { - onWantsToReply(note) - } - Spacer(modifier = StdHorzSpacer) - LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) + if (!note.isDraft()) { + ReplyReaction( + baseNote = note, + grayTint = MaterialTheme.colorScheme.placeholderText, + accountViewModel = accountViewModel, + showCounter = false, + iconSizeModifier = Size18Modifier, + ) { + onWantsToReply(note) + } + Spacer(modifier = StdHorzSpacer) + LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) - ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav) + } else { + DisplayDraftChat() + } } }, ) { backgroundBubbleColor -> @@ -260,221 +239,6 @@ fun NormalChatNote( } } -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun ChatBubbleLayout( - isLoggedInUser: Boolean, - innerQuote: Boolean, - isComplete: Boolean, - hasDetailsToShow: Boolean, - drawAuthorInfo: Boolean, - parentBackgroundColor: MutableState? = null, - onClick: () -> Boolean, - onAuthorClick: () -> Unit, - actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, - detailRow: @Composable () -> Unit, - drawAuthorLine: @Composable () -> Unit, - inner: @Composable (MutableState) -> Unit, -) { - val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink - val otherColors = MaterialTheme.colorScheme.chatBackground - val defaultBackground = MaterialTheme.colorScheme.background - - val backgroundBubbleColor = - remember { - if (isLoggedInUser) { - 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() { - val backgroundBubbleColor = - remember { - mutableStateOf(Color.Transparent) - } - - Column { - ChatBubbleLayout( - isLoggedInUser = false, - innerQuote = false, - isComplete = true, - hasDetailsToShow = true, - drawAuthorInfo = true, - parentBackgroundColor = backgroundBubbleColor, - onClick = { false }, - onAuthorClick = {}, - actionMenu = { onDismiss -> - }, - drawAuthorLine = { - UserDisplayNameLayout( - picture = { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(Size20dp) - .clip(CircleShape) - .background(Color.LightGray), - ) - }, - name = { - Text("Someone else", fontWeight = FontWeight.Bold) - }, - ) - }, - detailRow = { Text("Relays and Actions") }, - ) { backgroundBubbleColor -> - Text("This is my note") - } - - ChatBubbleLayout( - isLoggedInUser = true, - innerQuote = false, - isComplete = true, - hasDetailsToShow = true, - drawAuthorInfo = true, - parentBackgroundColor = backgroundBubbleColor, - onClick = { false }, - onAuthorClick = {}, - actionMenu = { onDismiss -> - }, - drawAuthorLine = { - UserDisplayNameLayout( - picture = { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(Size20dp) - .clip(CircleShape), - ) - }, - name = { - Text("Me", fontWeight = FontWeight.Bold) - }, - ) - }, - detailRow = { Text("Relays and Actions") }, - ) { backgroundBubbleColor -> - Text("This is a very long long loong note") - } - - ChatBubbleLayout( - isLoggedInUser = true, - innerQuote = false, - isComplete = false, - hasDetailsToShow = false, - drawAuthorInfo = false, - parentBackgroundColor = backgroundBubbleColor, - onClick = { false }, - onAuthorClick = {}, - actionMenu = { onDismiss -> - }, - drawAuthorLine = { - UserDisplayNameLayout( - picture = { - Icon( - imageVector = Icons.Default.Person, - contentDescription = null, - modifier = - Modifier - .size(Size20dp) - .clip(CircleShape), - ) - }, - name = { - Text("Me", fontWeight = FontWeight.Bold) - }, - ) - }, - detailRow = { Text("Relays and Actions") }, - ) { backgroundBubbleColor -> - Text("Short note") - } - } -} - @Composable private fun MessageBubbleLines( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomScreen.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomScreen.kt index e77750fca..2154dd1cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/ChatroomScreen.kt @@ -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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,7 +53,6 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -80,11 +79,9 @@ 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.Note 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.NewPostViewModel import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia @@ -106,6 +103,11 @@ 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.chatlist.DisplayRoomSubject +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.DisplayUserSetAsSubject +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.MyTextField import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -119,18 +121,10 @@ 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.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip10Notes.content.findHashtags -import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris -import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject -import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip92IMeta.imetas import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toPersistentList @@ -144,6 +138,8 @@ import kotlinx.coroutines.launch fun ChatroomScreen( roomId: String?, draftMessage: String? = null, + replyToNote: HexKey? = null, + editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -157,7 +153,7 @@ fun ChatroomScreen( accountViewModel = accountViewModel, ) { Column(Modifier.padding(it)) { - Chatroom(roomId, draftMessage, accountViewModel, nav) + Chatroom(roomId, draftMessage, replyToNote, editFromDraft, accountViewModel, nav) } } } @@ -238,6 +234,8 @@ private fun RenderRoomTopBar( fun Chatroom( roomId: String?, draftMessage: String? = null, + replyToNote: HexKey? = null, + editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -248,6 +246,8 @@ fun Chatroom( PrepareChatroomViewModels( room = it, draftMessage = draftMessage, + replyToNote = replyToNote, + editFromDraft = editFromDraft, accountViewModel = accountViewModel, nav = nav, ) @@ -306,6 +306,8 @@ fun ChatroomByAuthor( PrepareChatroomViewModels( room = it, draftMessage = draftMessage, + replyToNote = null, + editFromDraft = null, accountViewModel = accountViewModel, nav = nav, ) @@ -356,6 +358,8 @@ fun LoadRoomByAuthor( fun PrepareChatroomViewModels( room: ChatroomKey, draftMessage: String?, + replyToNote: HexKey? = null, + editFromDraft: HexKey? = null, accountViewModel: AccountViewModel, nav: INav, ) { @@ -369,29 +373,46 @@ fun PrepareChatroomViewModels( ), ) - val newPostModel: NewPostViewModel = viewModel() - newPostModel.accountViewModel = accountViewModel - newPostModel.account = accountViewModel.account - newPostModel.requiresNIP17 = room.users.size > 1 + val newPostModel: ChatNewMessageViewModel = viewModel() + newPostModel.init(accountViewModel) + newPostModel.load(room) - if (newPostModel.requiresNIP17) { - newPostModel.nip17 = true - } else { - if (room.users.size == 1) { - ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { - if (it?.relays().isNullOrEmpty()) { - newPostModel.nip17 = false - } else { - newPostModel.nip17 = true + 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)) } + LaunchedEffect(key1 = draftMessage) { + newPostModel.updateMessage(TextFieldValue(draftMessage)) + } } ChatroomScreen( @@ -408,7 +429,7 @@ fun PrepareChatroomViewModels( fun ChatroomScreen( room: ChatroomKey, feedViewModel: NostrChatroomFeedViewModel, - newPostModel: NewPostViewModel, + newPostModel: ChatNewMessageViewModel, fileUpload: ChatFileUploadModel, accountViewModel: AccountViewModel, nav: INav, @@ -444,7 +465,6 @@ fun ChatroomScreen( } Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) Column( @@ -460,18 +480,14 @@ fun ChatroomScreen( nav = nav, routeForLastRead = "Room/${room.hashCode()}", avoidDraft = newPostModel.draftTag, - onWantsToReply = { - replyTo.value = it - }, - onWantsToEditDraft = { - newPostModel.load(accountViewModel, null, null, null, null, it) - }, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, ) } Spacer(modifier = Modifier.height(10.dp)) - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } + newPostModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { newPostModel.replyTo.value = null } } val scope = rememberCoroutineScope() @@ -481,7 +497,7 @@ fun ChatroomScreen( .receiveAsFlow() .debounce(1000) .collectLatest { - innerSendPost(newPostModel, room, replyTo, accountViewModel, newPostModel.draftTag) + newPostModel.sendDraft() } } } @@ -501,85 +517,29 @@ fun ChatroomScreen( accountViewModel, onSendNewMessage = { scope.launch(Dispatchers.IO) { - innerSendPost(newPostModel, room, replyTo, accountViewModel, null) - - accountViewModel.deleteDraft(newPostModel.draftTag) - - newPostModel.message = TextFieldValue("") - - replyTo.value = null + newPostModel.sendPostSync() feedViewModel.sendToTop() } }, - onSendNewMedia = { - fileUpload.load(it, room, accountViewModel.account) + onSendNewMedia = { list, isNip17 -> + fileUpload.load(list, room, isNip17, accountViewModel.account) }, ) } } -private fun innerSendPost( - newPostModel: NewPostViewModel, - room: ChatroomKey, - replyTo: MutableState, - accountViewModel: AccountViewModel, - dTag: String?, -) { - val urls = findURLs(newPostModel.message.text) - val usedAttachments = newPostModel.iMetaAttachments.filter { it.url !in urls.toSet() } - val emojis = newPostModel.findEmoji(newPostModel.message.text, accountViewModel.account.myEmojis.value) - - val message = newPostModel.message.text - - if (newPostModel.nip17 || room.users.size > 1 || replyTo.value?.event is NIP17Group) { - val replyHint = replyTo.value?.toEventHint() - val template = - if (replyHint == null) { - ChatMessageEvent.build(message, room.users.map { LocalCache.getOrCreateUser(it).toPTag() }) { - hashtags(findHashtags(message)) - references(findURLs(message)) - quotes(findNostrUris(message)) - - emojis(emojis) - imetas(usedAttachments) - } - } else { - ChatMessageEvent.reply(message, replyHint) { - hashtags(findHashtags(message)) - references(findURLs(message)) - quotes(findNostrUris(message)) - - emojis(emojis) - imetas(usedAttachments) - } - } - - accountViewModel.account.sendNIP17PrivateMessage(template, dTag) - } else { - accountViewModel.account.sendPrivateMessage( - message = newPostModel.message.text, - toUser = room.users.first().let { LocalCache.getOrCreateUser(it).toPTag() }, - replyingTo = replyTo.value, - mentions = null, - contentWarningReason = null, - imetas = usedAttachments, - draftTag = dTag, - ) - } -} - @Composable fun PrivateMessageEditFieldRow( - channelScreenModel: NewPostViewModel, + channelScreenModel: ChatNewMessageViewModel, accountViewModel: AccountViewModel, onSendNewMessage: () -> Unit, - onSendNewMedia: (ImmutableList) -> Unit, + onSendNewMedia: (ImmutableList, isNip17: Boolean) -> Unit, ) { Column( modifier = EditFieldModifier, ) { ShowUserSuggestionList( - channelScreenModel.userSuggestions, + channelScreenModel.userSuggestions.userSuggestions, channelScreenModel::autocompleteWithUser, accountViewModel, ) @@ -623,11 +583,10 @@ fun PrivateMessageEditFieldRow( SelectFromGallery( isUploading = channelScreenModel.isUploadingImage, tint = MaterialTheme.colorScheme.placeholderText, - modifier = - Modifier - .size(30.dp) - .padding(start = 2.dp), - onImageChosen = onSendNewMedia, + modifier = Modifier.size(30.dp).padding(start = 2.dp), + onImageChosen = { + onSendNewMedia(it, channelScreenModel.nip17) + }, ) var wantsToActivateNIP17 by remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/IMetaAttachments.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/IMetaAttachments.kt new file mode 100644 index 000000000..84054bbf4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/IMetaAttachments.kt @@ -0,0 +1,104 @@ +/** + * 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.chatlist.private + +import android.webkit.MimeTypeMap +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.vitorpamplona.amethyst.service.uploads.FileHeader +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.quartz.nip92IMeta.IMetaTag +import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder +import com.vitorpamplona.quartz.nip92IMeta.imetaTagBuilder +import com.vitorpamplona.quartz.nip94FileMetadata.alt +import com.vitorpamplona.quartz.nip94FileMetadata.blurhash +import com.vitorpamplona.quartz.nip94FileMetadata.dims +import com.vitorpamplona.quartz.nip94FileMetadata.hash +import com.vitorpamplona.quartz.nip94FileMetadata.magnet +import com.vitorpamplona.quartz.nip94FileMetadata.mimeType +import com.vitorpamplona.quartz.nip94FileMetadata.originalHash +import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent +import com.vitorpamplona.quartz.nip94FileMetadata.size +import java.util.Locale + +class IMetaAttachments { + var iMetaAttachments by mutableStateOf>(emptyList()) + + suspend fun downloadAndPrepare( + url: String, + forceProxy: Boolean, + ) { + val fileExtension: String = MimeTypeMap.getFileExtensionFromUrl(url) + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.lowercase(Locale.getDefault())) + + val imeta = + FileHeader.prepare(url, mimeType, null, forceProxy).getOrNull()?.let { + IMetaTagBuilder(url) + .apply { + hash(it.hash) + size(it.size) + it.mimeType?.let { mimeType(it) } + it.dim?.let { dims(it) } + it.blurHash?.let { blurhash(it.blurhash) } + }.build() + } + + if (imeta != null) { + iMetaAttachments += imeta + } + } + + fun remove(url: String) { + iMetaAttachments = iMetaAttachments.filter { it.url != url } + } + + fun replace( + url: String, + iMeta: IMetaTag, + ) { + iMetaAttachments = iMetaAttachments.filter { it.url != url } + iMeta + } + + fun add( + result: UploadOrchestrator.OrchestratorResult.ServerResult, + alt: String?, + contentWarningReason: String?, + ) { + val iMeta = + imetaTagBuilder(result.url) { + hash(result.fileHeader.hash) + size(result.fileHeader.size) + result.fileHeader.mimeType?.let { mimeType(it) } + result.fileHeader.dim?.let { dims(it) } + result.fileHeader.blurHash?.let { blurhash(it.blurhash) } + result.magnet?.let { magnet(it) } + result.uploadedHash?.let { originalHash(it) } + + alt?.let { alt(it) } + contentWarningReason?.let { sensitiveContent(contentWarningReason) } + } + + replace(iMeta.url, iMeta) + } + + fun filterIsIn(urls: Set) = iMetaAttachments.filter { it.url !in urls } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/UserSuggestions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/UserSuggestions.kt new file mode 100644 index 000000000..9d500202e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/private/UserSuggestions.kt @@ -0,0 +1,71 @@ +/** + * 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.chatlist.private + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class UserSuggestions { + var userSuggestions by mutableStateOf>(emptyList()) + + fun reset() { + userSuggestions = emptyList() + } + + fun processCurrentWord( + word: String, + accountViewModel: AccountViewModel, + ) { + if (word.startsWith("@") && word.length > 2) { + val prefix = word.removePrefix("@") + NostrSearchEventOrUserDataSource.search(prefix) + accountViewModel.viewModelScope.launch(Dispatchers.IO) { + userSuggestions = accountViewModel.findUsersStartingWithSync(prefix) + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + + fun replaceCurrentWord( + message: TextFieldValue, + word: String, + item: User, + ): TextFieldValue { + val lastWordStart = message.selection.end - word.length + val wordToInsert = "@${item.pubkeyNpub()}" + + return TextFieldValue( + message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/public/ChannelScreen.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/public/ChannelScreen.kt index d58262f92..add83b401 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/public/ChannelScreen.kt @@ -18,19 +18,14 @@ * 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.chatrooms +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -38,30 +33,20 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.selection.LocalTextSelectionColors -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.Share import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState @@ -71,25 +56,19 @@ 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle 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.input.VisualTransformation import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -141,6 +120,10 @@ 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.chatlist.private.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.MyTextField import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.stringRes @@ -501,52 +484,6 @@ private suspend fun innerSendPost( } } -@Composable -fun DisplayReplyingToNote( - replyingNote: Note?, - accountViewModel: AccountViewModel, - nav: INav, - onCancel: () -> Unit, -) { - Row( - Modifier - .padding(horizontal = 10.dp) - .heightIn(max = 100.dp) - .verticalScroll(rememberScrollState()) - .animateContentSize(), - ) { - if (replyingNote != null) { - Column(remember { Modifier.weight(1f) }) { - ChatroomMessageCompose( - baseNote = replyingNote, - null, - innerQuote = true, - accountViewModel = accountViewModel, - nav = nav, - onWantsToReply = {}, - onWantsToEditDraft = {}, - ) - } - - Column(Modifier.padding(start = 5.dp)) { - IconButton( - modifier = Modifier.size(20.dp), - onClick = onCancel, - ) { - Icon( - imageVector = Icons.Default.Cancel, - null, - modifier = - Modifier - .size(20.dp), - tint = MaterialTheme.colorScheme.placeholderText, - ) - } - } - } - } -} - @Composable fun EditFieldRow( channelScreenModel: NewPostViewModel, @@ -624,113 +561,6 @@ fun EditFieldRow( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MyTextField( - value: TextFieldValue, - onValueChange: (TextFieldValue) -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - readOnly: Boolean = false, - textStyle: TextStyle = LocalTextStyle.current, - label: @Composable (() -> Unit)? = null, - placeholder: @Composable (() -> Unit)? = null, - leadingIcon: @Composable (() -> Unit)? = null, - trailingIcon: @Composable (() -> Unit)? = null, - prefix: @Composable (() -> Unit)? = null, - suffix: @Composable (() -> Unit)? = null, - supportingText: @Composable (() -> Unit)? = null, - isError: Boolean = false, - visualTransformation: VisualTransformation = VisualTransformation.None, - keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - keyboardActions: KeyboardActions = KeyboardActions.Default, - singleLine: Boolean = false, - maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, - minLines: Int = 1, - interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - shape: Shape = TextFieldDefaults.shape, - colors: TextFieldColors = TextFieldDefaults.colors(), - contentPadding: PaddingValues = - if (label == null) { - TextFieldDefaults.contentPaddingWithoutLabel( - start = 10.dp, - top = 12.dp, - end = 10.dp, - bottom = 12.dp, - ) - } else { - TextFieldDefaults.contentPaddingWithLabel( - start = 10.dp, - top = 12.dp, - end = 10.dp, - bottom = 12.dp, - ) - }, -) { - // COPIED FROM TEXT FIELD - // The only change is the contentPadding below - val textColor = - textStyle.color.takeOrElse { - val focused by interactionSource.collectIsFocusedAsState() - - val targetValue = - when { - !enabled -> MaterialTheme.colorScheme.placeholderText - isError -> MaterialTheme.colorScheme.onSurface - focused -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface - } - - rememberUpdatedState(targetValue).value - } - val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) - - CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { - BasicTextField( - value = value, - modifier = - modifier.defaultMinSize( - minWidth = TextFieldDefaults.MinWidth, - minHeight = 36.dp, - ), - onValueChange = onValueChange, - enabled = enabled, - readOnly = readOnly, - textStyle = mergedTextStyle, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions, - interactionSource = interactionSource, - singleLine = singleLine, - maxLines = maxLines, - minLines = minLines, - decorationBox = - @Composable { innerTextField -> - TextFieldDefaults.DecorationBox( - value = value.text, - visualTransformation = visualTransformation, - innerTextField = innerTextField, - placeholder = placeholder, - label = label, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, - prefix = prefix, - suffix = suffix, - supportingText = supportingText, - shape = shape, - singleLine = singleLine, - enabled = enabled, - isError = isError, - interactionSource = interactionSource, - colors = colors, - contentPadding = contentPadding, - ) - }, - ) - } -} - @Composable fun RenderChannelHeader( channelNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/utils/DisplayReplyingToNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/utils/DisplayReplyingToNote.kt new file mode 100644 index 000000000..82bbb718f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/utils/DisplayReplyingToNote.kt @@ -0,0 +1,88 @@ +/** + * 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.chatlist.utils + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +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.chatlist.private.ChatroomMessageCompose +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun DisplayReplyingToNote( + replyingNote: Note?, + accountViewModel: AccountViewModel, + nav: INav, + onCancel: () -> Unit, +) { + Row( + Modifier + .padding(horizontal = 10.dp) + .heightIn(max = 100.dp) + .verticalScroll(rememberScrollState()) + .animateContentSize(), + ) { + if (replyingNote != null) { + Column(remember { Modifier.weight(1f) }) { + ChatroomMessageCompose( + baseNote = replyingNote, + null, + innerQuote = true, + accountViewModel = accountViewModel, + nav = nav, + onWantsToReply = {}, + onWantsToEditDraft = {}, + ) + } + + Column(Modifier.padding(start = 5.dp)) { + IconButton( + modifier = Size20Modifier, + onClick = onCancel, + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Size20Modifier, + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/utils/MyTextField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/utils/MyTextField.kt new file mode 100644 index 000000000..d0a5393ff --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatlist/utils/MyTextField.kt @@ -0,0 +1,156 @@ +/** + * 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.chatlist.utils + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyTextField( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + suffix: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), + contentPadding: PaddingValues = + if (label == null) { + TextFieldDefaults.contentPaddingWithoutLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + } else { + TextFieldDefaults.contentPaddingWithLabel( + start = 10.dp, + top = 12.dp, + end = 10.dp, + bottom = 12.dp, + ) + }, +) { + // COPIED FROM TEXT FIELD + // The only change is the contentPadding below + val textColor = + textStyle.color.takeOrElse { + val focused by interactionSource.collectIsFocusedAsState() + + val targetValue = + when { + !enabled -> MaterialTheme.colorScheme.placeholderText + isError -> MaterialTheme.colorScheme.onSurface + focused -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface + } + + rememberUpdatedState(targetValue).value + } + val mergedTextStyle = textStyle.merge(TextStyle(color = textColor)) + + CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) { + BasicTextField( + value = value, + modifier = + modifier.defaultMinSize( + minWidth = TextFieldDefaults.MinWidth, + minHeight = 36.dp, + ), + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = mergedTextStyle, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + decorationBox = + @Composable { innerTextField -> + TextFieldDefaults.DecorationBox( + value = value.text, + visualTransformation = visualTransformation, + innerTextField = innerTextField, + placeholder = placeholder, + label = label, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + prefix = prefix, + suffix = suffix, + supportingText = supportingText, + shape = shape, + singleLine = singleLine, + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + contentPadding = contentPadding, + ) + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt index b34434435..f80a8a02c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayLNAddress.kt @@ -50,8 +50,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun DisplayLNAddress( @@ -71,9 +69,8 @@ fun DisplayLNAddress( title = stringRes(id = R.string.error_dialog_zap_error), textContent = showErrorMessageDialog ?: "", onClickStartMessage = { - scope.launch(Dispatchers.IO) { - val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) - nav.nav(route) + nav.nav { + routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel) } }, onDismiss = { showErrorMessageDialog = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index 2c2b9c88d..0c4dcacfe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.ChannelName import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.FeedPadding diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index e102802be..3c7dff356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -52,7 +52,6 @@ import androidx.compose.runtime.State 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 @@ -148,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.chatrooms.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness @@ -213,7 +212,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext @@ -858,7 +856,6 @@ private fun RenderClassifiedsReaderForThread( } var message by remember { mutableStateOf(TextFieldValue(msg)) } - val scope = rememberCoroutineScope() TextField( value = message, @@ -881,9 +878,13 @@ private fun RenderClassifiedsReaderForThread( isActive = message.text.isNotBlank(), modifier = EditFieldTrailingIconModifier, ) { - scope.launch(Dispatchers.IO) { - note.author?.let { - nav.nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel)) + note.author?.let { + nav.nav { + routeToMessage( + it, + note.toNostrUri() + "\n\n" + msg, + accountViewModel = accountViewModel, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 4ee2a971d..114676679 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -266,7 +266,7 @@ val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape) val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp) val messageDetailsModifier = Modifier.height(Size25dp) -val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp) +val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 5.dp) val inlinePlaceholder = Placeholder( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 17e6df82c..19e0126ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -119,6 +119,9 @@ private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.05f) private val DarkChatBackground = DarkColorPalette.onSurface.copy(alpha = 0.12f) private val LightChatBackground = LightColorPalette.onSurface.copy(alpha = 0.08f) +private val DarkChatDraftBackground = DarkColorPalette.onSurface.copy(alpha = 0.15f) +private val LightChatDraftBackground = LightColorPalette.onSurface.copy(alpha = 0.15f) + private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) @@ -387,6 +390,9 @@ val ColorScheme.subtleBorder: Color val ColorScheme.chatBackground: Color get() = if (isLight) LightChatBackground else DarkChatBackground +val ColorScheme.chatDraftBackground: Color + get() = if (isLight) LightChatDraftBackground else DarkChatDraftBackground + val ColorScheme.subtleButton: Color get() = if (isLight) LightSubtleButton else DarkSubtleButton diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt index ffc4f2a0a..e9cca78b3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.compose import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import kotlin.math.max +import kotlin.math.min fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue { var toInsert = url.trim() @@ -41,3 +43,54 @@ fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue { TextRange(endOfUrlIndex, endOfUrlIndex), ) } + +fun TextFieldValue.replaceCurrentWord(wordToInsert: String): TextFieldValue { + val lastWordStart = currentWordStartIdx() + val lastWordEnd = currentWordEndIdx() + val cursor = lastWordStart + wordToInsert.length + return TextFieldValue( + text.replaceRange(lastWordStart, lastWordEnd, wordToInsert), + TextRange(cursor, cursor), + ) +} + +fun TextFieldValue.currentWordStartIdx(): Int { + val previousNewLine = text.lastIndexOf('\n', selection.start - 1) + val previousSpace = text.lastIndexOf(' ', selection.start - 1) + + return max( + previousNewLine, + previousSpace, + ) + 1 +} + +fun TextFieldValue.currentWordEndIdx(): Int { + val nextNewLine = text.indexOf('\n', selection.end) + val nextSpace = text.indexOf(' ', selection.end) + + if (nextSpace < 0 && nextNewLine < 0) return selection.end + if (nextSpace > 0 && nextNewLine > 0) { + return min( + nextNewLine, + nextSpace, + ) + } + if (nextSpace > 0) { + return nextSpace + } + return nextNewLine +} + +fun TextFieldValue.currentWord(): String { + if (selection.end != selection.start) return "" + + val start = currentWordStartIdx() + val end = currentWordEndIdx() + + return if (start < end) { + val word = text.substring(start, end) + word + } else { + "" + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt index a030e3bca..d09eae45b 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/tags/addressables/Address.kt @@ -50,7 +50,7 @@ data class Address( fun parse(addressId: String): Address? = try { val parts = addressId.split(":", limit = 3) - if (parts[1].length == 64 && Hex.isHex(parts[1])) { + if (parts.size > 1 && parts[1].length == 64 && Hex.isHex(parts[1])) { Address(parts[0].toInt(), parts[1], parts[2]) } else { Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt index 8679363ea..cf9292b35 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/ChatMessageEvent.kt @@ -60,11 +60,12 @@ class ChatMessageEvent( fun reply( msg: String, - reply: EventHintBundle, + reply: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, msg, createdAt) { alt(ALT_DESCRIPTION) + reply(reply) group((reply.event.recipients() + reply.toPTag()).distinctBy { it.pubKey }) initializer() } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt index 84c0059ae..142a18c1d 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip17Dm/messages/TagArrayBuilderExt.kt @@ -27,10 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip14Subject.SubjectTag +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent fun TagArrayBuilder.reply(msg: MarkedETag) = add(msg.toTagArray()) -fun TagArrayBuilder.reply(msg: EventHintBundle) = reply(msg.toMarkedETag(MarkedETag.MARKER.REPLY)) +fun TagArrayBuilder.reply(msg: EventHintBundle) = reply(msg.toMarkedETag(MarkedETag.MARKER.REPLY)) fun TagArrayBuilder.group(list: List) = pTags(list) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt index b3dec6802..f61414917 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiUrlTag.kt @@ -29,13 +29,15 @@ data class EmojiUrlTag( ) { fun encode(): String = ":$code:$url" - fun toContentEncode(): String = ":$code" + fun toContentEncode(): String = contentEncode(code) fun toTagArray() = arrayOf("emoji", code, url) companion object { const val TAG_NAME = "emoji" + fun contentEncode(code: String): String = ":$code:" + fun decode(encodedEmojiSetup: String): EmojiUrlTag? { val emojiParts = encodedEmojiSetup.split(":", limit = 3) return if (emojiParts.size > 2) { diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt index 1ee58482b..94bd92c2a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip92IMeta/IMetaTagBuilder.kt @@ -35,3 +35,8 @@ class IMetaTagBuilder( fun build() = IMetaTag(url, properties) } + +fun imetaTagBuilder( + url: String, + init: IMetaTagBuilder.() -> Unit, +) = IMetaTagBuilder(url).apply(init).build()