- Migrates NIP-17 DM new message model to its own model and disable some of the generic behavior from new posts.

- Fixes a bug on edits showing a previous edit.
- Refactors user suggestions and lastword procedures
- Adds a DM inline reply from other parts of the app, like on notification.
- Hides Retweet button from DMs on Notifications
- Fixes colors for drafts in chats.
- Adds a nav function that computes the destination only after the user clicks on it and on a coroutine.
This commit is contained in:
Vitor Pamplona
2025-03-04 17:57:47 -05:00
parent 1a001e7bb3
commit 0580aef4a2
54 changed files with 1858 additions and 698 deletions
@@ -2577,7 +2577,6 @@ class Account(
message: String, message: String,
toUser: User, toUser: User,
replyingTo: Note? = null, replyingTo: Note? = null,
mentions: List<User>?,
zapReceiver: List<ZapSplitSetup>? = null, zapReceiver: List<ZapSplitSetup>? = null,
contentWarningReason: String? = null, contentWarningReason: String? = null,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
@@ -2590,7 +2589,6 @@ class Account(
message, message,
toUser.toPTag(), toUser.toPTag(),
replyingTo, replyingTo,
mentions,
zapReceiver, zapReceiver,
contentWarningReason, contentWarningReason,
zapRaiserAmount, zapRaiserAmount,
@@ -2605,7 +2603,6 @@ class Account(
message: String, message: String,
toUser: PTag, toUser: PTag,
replyingTo: Note? = null, replyingTo: Note? = null,
mentions: List<User>?,
zapReceiver: List<ZapSplitSetup>? = null, zapReceiver: List<ZapSplitSetup>? = null,
contentWarningReason: String? = null, contentWarningReason: String? = null,
zapRaiserAmount: Long? = null, zapRaiserAmount: Long? = null,
@@ -3183,10 +3180,16 @@ class Account(
fun cachedDecryptContent(event: Event?): String? { fun cachedDecryptContent(event: Event?): String? {
if (event == null) return null if (event == null) return null
return if (event is PrivateDmEvent && isWriteable()) { return if (isWriteable()) {
event.cachedContentFor(signer) if (event is PrivateDmEvent) {
} else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) { event.cachedContentFor(signer)
event.cachedPrivateZap()?.content } else if (event is LnZapRequestEvent && event.isPrivateZap()) {
event.cachedPrivateZap()?.content
} else if (event is DraftEvent) {
event.preCachedDraft(signer)?.content
} else {
event.content
}
} else { } else {
event.content event.content
} }
@@ -236,16 +236,13 @@ class User(
}.flatten() }.flatten()
@Synchronized @Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom { private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom =
checkNotInMainThread() privateChatrooms[key]
return privateChatrooms[key]
?: run { ?: run {
val privateChatroom = Chatroom() val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(key, privateChatroom) privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom privateChatroom
} }
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom { private fun getOrCreatePrivateChatroom(user: User): Chatroom {
val key = ChatroomKey(persistentSetOf(user.pubkeyHex)) val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
@@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton 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.screen.loggedIn.search.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -86,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip10Notes.tags.notify import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags
import com.vitorpamplona.quartz.nip14Subject.subject 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.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject
@@ -818,7 +819,6 @@ open class NewPostViewModel : ViewModel() {
message = tagger.message, message = tagger.message,
toUser = originalNote!!.author!!, toUser = originalNote!!.author!!,
replyingTo = originalNote!!, replyingTo = originalNote!!,
mentions = tagger.pTags,
zapReceiver = zapReceiver, zapReceiver = zapReceiver,
contentWarningReason = contentWarningReason, contentWarningReason = contentWarningReason,
zapRaiserAmount = localZapRaiserAmount, zapRaiserAmount = localZapRaiserAmount,
@@ -827,7 +827,7 @@ open class NewPostViewModel : ViewModel() {
draftTag = localDraft, draftTag = localDraft,
) )
} else if (originalNote?.event is NIP17Group) { } else if (originalNote?.event is NIP17Group) {
val replyHint = originalNote?.toEventHint<ChatMessageEvent>() val replyHint = originalNote?.toEventHint<BaseDMGroupEvent>()
val template = val template =
if (replyHint == null) { if (replyHint == null) {
@@ -868,7 +868,7 @@ open class NewPostViewModel : ViewModel() {
account?.sendNIP17PrivateMessage(template, localDraft) account?.sendNIP17PrivateMessage(template, localDraft)
} else if (!dmUsers.isNullOrEmpty()) { } else if (!dmUsers.isNullOrEmpty()) {
if (nip17 || dmUsers.size > 1) { if (nip17 || dmUsers.size > 1) {
val replyHint = originalNote?.toEventHint<ChatMessageEvent>() val replyHint = originalNote?.toEventHint<BaseDMGroupEvent>()
val template = val template =
if (replyHint == null) { if (replyHint == null) {
ChatMessageEvent.build(tagger.message, dmUsers.map { it.toPTag() }) { ChatMessageEvent.build(tagger.message, dmUsers.map { it.toPTag() }) {
@@ -910,7 +910,6 @@ open class NewPostViewModel : ViewModel() {
message = tagger.message, message = tagger.message,
toUser = dmUsers.first(), toUser = dmUsers.first(),
replyingTo = originalNote, replyingTo = originalNote,
mentions = tagger.pTags,
contentWarningReason = contentWarningReason, contentWarningReason = contentWarningReason,
zapReceiver = zapReceiver, zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount, zapRaiserAmount = localZapRaiserAmount,
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -91,7 +91,7 @@ import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
@@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -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.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen 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.chatlist.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.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.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
@@ -94,6 +94,16 @@ fun NavBackStackEntry.message(): String? =
URLDecoder.decode(it, "utf-8") 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 @Composable
fun AppNavigation( fun AppNavigation(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
@@ -219,6 +229,8 @@ fun AppNavigation(
ChatroomScreen( ChatroomScreen(
roomId = it.id(), roomId = it.id(),
draftMessage = it.message(), draftMessage = it.message(),
replyToNote = it.replyId(),
editFromDraft = it.draftId(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -54,6 +54,8 @@ interface INav {
fun nav(route: String) fun nav(route: String)
fun nav(computeRoute: suspend () -> String)
fun newStack(route: String) fun newStack(route: String)
fun popBack() 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) { override fun newStack(route: String) {
scope.launch { scope.launch {
controller.navigate(route) { controller.navigate(route) {
@@ -135,6 +146,9 @@ object EmptyNav : INav {
override fun nav(route: String) { override fun nav(route: String) {
} }
override fun nav(computeRoute: suspend () -> String) {
}
override fun newStack(route: String) { override fun newStack(route: String) {
} }
@@ -161,6 +175,11 @@ class ObservableNavigate(
nav.nav(route) nav.nav(route)
} }
override fun nav(computeRoute: suspend () -> String) {
onNavigate()
nav.nav(computeRoute)
}
override fun newStack(route: String) { override fun newStack(route: String) {
onNavigate() onNavigate()
nav.newStack(route) nav.newStack(route)
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import com.vitorpamplona.amethyst.model.Channel import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache.users
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event 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.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.collections.immutable.persistentSetOf
import java.net.URLEncoder import java.net.URLEncoder
fun routeFor( fun routeFor(
@@ -110,23 +111,71 @@ fun routeFor(
fun routeToMessage( fun routeToMessage(
user: HexKey, user: HexKey,
draftMessage: String?, draftMessage: String?,
replyId: HexKey? = null,
quoteId: HexKey? = null,
accountViewModel: AccountViewModel,
): String =
routeToMessage(
setOf(user),
draftMessage,
replyId,
quoteId,
accountViewModel,
)
fun routeToMessage(
users: Set<HexKey>,
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, accountViewModel: AccountViewModel,
): String { ): String {
val withKey = ChatroomKey(persistentSetOf(user)) accountViewModel.account.userProfile().createChatroom(room)
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) { val params =
val encodedMessage = URLEncoder.encode(draftMessage, "utf-8") listOfNotNull(
"Room/${withKey.hashCode()}?message=$encodedMessage" draftMessage?.let {
} else { "message=${URLEncoder.encode(it, "utf-8")}"
"Room/${withKey.hashCode()}" },
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( fun routeToMessage(
user: User, user: User,
draftMessage: String?, draftMessage: String?,
replyId: HexKey? = null,
quoteId: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): String = routeToMessage(user.pubkeyHex, draftMessage, accountViewModel) ): String = routeToMessage(user.pubkeyHex, draftMessage, replyId, quoteId, accountViewModel)
fun routeFor(note: Channel): String = "Channel/${note.idHex}" fun routeFor(note: Channel): String = "Channel/${note.idHex}"
@@ -169,7 +169,7 @@ sealed class Route(
object Room : object Room :
Route( Route(
route = "Room/{id}?message={message}", route = "Room/{id}?message={message}&replyId={replyId}&draftId={draftId}",
icon = R.drawable.ic_moments, icon = R.drawable.ic_moments,
arguments = arguments =
listOf( listOf(
@@ -179,6 +179,16 @@ sealed class Route(
nullable = true nullable = true
defaultValue = null defaultValue = null
}, },
navArgument("replyId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("draftId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(), ).toImmutableList(),
) )
@@ -78,11 +78,11 @@ import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.screen.loggedIn.chatrooms.EndedFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.OfflineFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag 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.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -25,7 +25,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -73,15 +72,15 @@ fun LoadDecryptedContentOrNull(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
inner: @Composable (String?) -> Unit, inner: @Composable (String?) -> Unit,
) { ) {
@Suppress("ProduceStateDoesNotAssignValue") var decryptedContent by remember(note.event?.id) { mutableStateOf(accountViewModel.cachedDecrypt(note)) }
val decryptedContent by
produceState(initialValue = accountViewModel.cachedDecrypt(note), key1 = note.event?.id) { LaunchedEffect(note.event?.id) {
accountViewModel.decrypt(note) { accountViewModel.decrypt(note) {
if (value != it) { if (decryptedContent != it) {
value = it decryptedContent = it
}
} }
} }
}
inner(decryptedContent) inner(decryptedContent)
} }
@@ -43,7 +43,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@@ -74,7 +73,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -202,15 +200,14 @@ fun ErrorRow(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
errorState.user?.let { errorState.user?.let {
val scope = rememberCoroutineScope()
Column(Modifier.width(Size40dp), horizontalAlignment = Alignment.Start) { Column(Modifier.width(Size40dp), horizontalAlignment = Alignment.Start) {
UserPicture(errorState.user, Size30dp, Modifier, accountViewModel, nav) UserPicture(errorState.user, Size30dp, Modifier, accountViewModel, nav)
Spacer(StdVertSpacer) Spacer(StdVertSpacer)
IconButton( IconButton(
modifier = Size30Modifier, modifier = Size30Modifier,
onClick = { onClick = {
scope.launch(Dispatchers.IO) { nav.nav {
nav.nav(routeToMessage(it, errorState.error, accountViewModel)) routeToMessage(it, errorState.error, accountViewModel = accountViewModel)
} }
}, },
) { ) {
@@ -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.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer 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.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
@@ -1022,7 +1021,7 @@ fun DisplayDraftChat() {
Text( Text(
"Draft", "Draft",
color = MaterialTheme.colorScheme.placeholderText, color = MaterialTheme.colorScheme.placeholderText,
modifier = HalfEndPadding, modifier = Modifier,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = Font12SP, fontSize = Font12SP,
maxLines = 1, maxLines = 1,
@@ -627,7 +627,11 @@ fun ZapVote(
title = toast.title, title = toast.title,
textContent = toast.msg, textContent = toast.msg,
onClickStartMessage = { 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 }, onDismiss = { showErrorMessageDialog = null },
) )
@@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute 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.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes 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.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -209,13 +212,16 @@ private fun InnerReactionRow(
) )
}, },
three = { three = {
BoostWithDialog( val isDM = baseNote.event is ChatroomKeyable
baseNote, if (!isDM) {
editState, BoostWithDialog(
MaterialTheme.colorScheme.placeholderText, baseNote,
accountViewModel, editState,
nav, MaterialTheme.colorScheme.placeholderText,
) accountViewModel,
nav,
)
}
}, },
four = { four = {
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
@@ -558,56 +564,46 @@ private fun BoostWithDialog(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
var wantsToQuote by remember { mutableStateOf<Note?>(null) }
var wantsToFork by remember { mutableStateOf<Note?>(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( BoostReaction(
baseNote, baseNote,
grayTint, grayTint,
accountViewModel, accountViewModel,
onQuotePress = { wantsToQuote = baseNote }, onQuotePress = {
onForkPress = { wantsToFork = baseNote }, 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, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
var wantsToReplyTo by remember { mutableStateOf<Note?>(null) } ReplyReaction(baseNote, grayTint, accountViewModel) {
val noteEvent = baseNote.event
if (wantsToReplyTo != null) { if (noteEvent is PrivateDmEvent) {
val route = nav.nav {
buildNewPostRoute( routeToMessage(
baseReplyTo = wantsToReplyTo?.idHex, room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex),
quote = null, draftMessage = null,
) replyId = noteEvent.id,
nav.nav(route) 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 @Composable
@@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer 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.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
@@ -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.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.JoinCommunityButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LeaveCommunityButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -58,8 +58,8 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag 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.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -1666,6 +1666,8 @@ class AccountViewModel(
} }
} }
suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account)
fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow() fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow()
val draftNoteCache = CachedDraftNotes(this) val draftNoteCache = CachedDraftNotes(this)
@@ -162,7 +162,7 @@ import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.WatchAndLoadMyEmojiList import com.vitorpamplona.amethyst.ui.note.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon 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.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.animation.core.tween
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box 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.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -121,6 +124,7 @@ fun ChatroomListScreen(
class TwoPaneNav( class TwoPaneNav(
val nav: INav, val nav: INav,
val scope: CoroutineScope,
) : INav { ) : INav {
override val drawerState: DrawerState = nav.drawerState 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) { override fun newStack(route: String) {
nav.newStack(route) nav.newStack(route)
} }
@@ -172,7 +187,8 @@ fun ChatroomListTwoPane(
nav: INav, nav: INav,
) { ) {
/** The index of the currently selected word, or `null` if none is selected */ /** 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 = val strategy =
remember { remember {
@@ -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>(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<Color>? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
val otherColors = MaterialTheme.colorScheme.chatBackground
val defaultBackground = MaterialTheme.colorScheme.background
val draftColor = MaterialTheme.colorScheme.chatDraftBackground
val backgroundBubbleColor =
remember {
if (isLoggedInUser) {
if (isDraft) {
mutableStateOf(
draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
} else {
mutableStateOf(
loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground),
)
}
} else {
mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground))
}
}
Row(
modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
) {
val popupExpanded = remember { mutableStateOf(false) }
val showDetails =
remember {
mutableStateOf(
if (isComplete) {
true
} else {
hasDetailsToShow
},
)
}
val clickableModifier =
remember {
Modifier.combinedClickable(
onClick = {
if (!onClick()) {
if (!isComplete) {
showDetails.value = !showDetails.value
}
}
},
onLongClick = { popupExpanded.value = true },
)
}
Row(
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier,
) {
Surface(
color = backgroundBubbleColor.value,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem,
modifier = clickableModifier,
) {
Column(modifier = messageBubbleLimits, verticalArrangement = RowColSpacing5dp) {
if (drawAuthorInfo) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start,
modifier = HalfHalfVertPadding.clickable(onClick = onAuthorClick),
) {
drawAuthorLine()
}
}
inner(backgroundBubbleColor)
if (showDetails.value) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = ReactionRowHeightChat,
) {
detailRow()
}
}
}
}
}
if (popupExpanded.value) {
actionMenu {
popupExpanded.value = false
}
}
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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 android.content.Context
import androidx.compose.runtime.Stable 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.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.stringRes 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.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
open class ChatFileUploadModel : ViewModel() { open class ChatFileUploadModel : ViewModel() {
var account: Account? = null var account: Account? = null
var chatroom: ChatroomKey? = null var chatroom: ChatroomKey? = null
var isNip17: Boolean = false
var isUploadingImage by mutableStateOf(false) var isUploadingImage by mutableStateOf(false)
@@ -69,6 +72,7 @@ open class ChatFileUploadModel : ViewModel() {
open fun load( open fun load(
uris: ImmutableList<SelectedMedia>, uris: ImmutableList<SelectedMedia>,
chatroom: ChatroomKey, chatroom: ChatroomKey,
isNip17: Boolean,
account: Account, account: Account,
) { ) {
this.chatroom = chatroom this.chatroom = chatroom
@@ -76,6 +80,7 @@ open class ChatFileUploadModel : ViewModel() {
this.account = account this.account = account
this.multiOrchestrator = MultiOrchestrator(uris) this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer() this.selectedServer = defaultServer()
this.isNip17 = isNip17
} }
fun isImage( fun isImage(
@@ -87,6 +92,18 @@ open class ChatFileUploadModel : ViewModel() {
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
context: Context, context: Context,
onceUploaded: () -> Unit, 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 myAccount = account ?: return
val mySelectedServer = selectedServer ?: 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() { open fun cancelModel() {
multiOrchestrator = null multiOrchestrator = null
isUploadingImage = false isUploadingImage = false
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -148,9 +148,8 @@ fun ChatFileUploadView(
postViewModel.upload( postViewModel.upload(
onError = accountViewModel::toast, onError = accountViewModel::toast,
context = context, context = context,
) { onceUploaded = onClose,
onClose )
}
postViewModel.selectedServer?.let { postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) { if (it.type != ServerType.NIP95) {
@@ -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<Note?>(null)
val iMetaAttachments = IMetaAttachments()
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val userSuggestions = UserSuggestions()
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
val emojiSearch: MutableStateFlow<String> = MutableStateFlow("")
val emojiSuggestions: StateFlow<List<Account.EmojiMedia>> 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<MultiOrchestrator?>(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<User>>(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<Long?>(null)
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
val draftTextChanges = Channel<String>(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<BaseDMGroupEvent>()
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<Account.EmojiMedia>?,
): List<EmojiUrlTag> {
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<Int, String> = 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<SelectedMedia>) {
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()
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.animation.core.tween
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -18,25 +18,16 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size 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.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -44,17 +35,13 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note 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.note.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes 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.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP 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.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5Modifier import com.vitorpamplona.amethyst.ui.theme.Size5Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox 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.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.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
@@ -188,6 +164,7 @@ fun NormalChatNote(
ChatBubbleLayout( ChatBubbleLayout(
isLoggedInUser = isLoggedInUser, isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftEvent,
innerQuote = innerQuote, innerQuote = innerQuote,
isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE, isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE,
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
@@ -223,27 +200,29 @@ fun NormalChatNote(
) )
}, },
detailRow = { detailRow = {
if (note.isDraft()) {
DisplayDraftChat()
}
IncognitoBadge(note) IncognitoBadge(note)
ChatTimeAgo(note) ChatTimeAgo(note)
RelayBadgesHorizontal(note, accountViewModel, nav = nav) RelayBadgesHorizontal(note, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer) Spacer(modifier = DoubleHorzSpacer)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
ReplyReaction( if (!note.isDraft()) {
baseNote = note, ReplyReaction(
grayTint = MaterialTheme.colorScheme.placeholderText, baseNote = note,
accountViewModel = accountViewModel, grayTint = MaterialTheme.colorScheme.placeholderText,
showCounter = false, accountViewModel = accountViewModel,
iconSizeModifier = Size18Modifier, showCounter = false,
) { iconSizeModifier = Size18Modifier,
onWantsToReply(note) ) {
} onWantsToReply(note)
Spacer(modifier = StdHorzSpacer) }
LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav) 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 -> ) { 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<Color>? = null,
onClick: () -> Boolean,
onAuthorClick: () -> Unit,
actionMenu: @Composable (onDismiss: () -> Unit) -> Unit,
detailRow: @Composable () -> Unit,
drawAuthorLine: @Composable () -> Unit,
inner: @Composable (MutableState<Color>) -> Unit,
) {
val loggedInColors = MaterialTheme.colorScheme.mediumImportanceLink
val otherColors = MaterialTheme.colorScheme.chatBackground
val defaultBackground = MaterialTheme.colorScheme.background
val 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>(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 @Composable
private fun MessageBubbleLines( private fun MessageBubbleLines(
baseNote: Note, baseNote: Note,
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -53,7 +53,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -80,11 +79,9 @@ import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled 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.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia 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.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness 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.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.core.HexKey
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.nip17Dm.base.ChatroomKey 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.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject 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.ImmutableList
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
@@ -144,6 +138,8 @@ import kotlinx.coroutines.launch
fun ChatroomScreen( fun ChatroomScreen(
roomId: String?, roomId: String?,
draftMessage: String? = null, draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -157,7 +153,7 @@ fun ChatroomScreen(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
) { ) {
Column(Modifier.padding(it)) { 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( fun Chatroom(
roomId: String?, roomId: String?,
draftMessage: String? = null, draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -248,6 +246,8 @@ fun Chatroom(
PrepareChatroomViewModels( PrepareChatroomViewModels(
room = it, room = it,
draftMessage = draftMessage, draftMessage = draftMessage,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -306,6 +306,8 @@ fun ChatroomByAuthor(
PrepareChatroomViewModels( PrepareChatroomViewModels(
room = it, room = it,
draftMessage = draftMessage, draftMessage = draftMessage,
replyToNote = null,
editFromDraft = null,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -356,6 +358,8 @@ fun LoadRoomByAuthor(
fun PrepareChatroomViewModels( fun PrepareChatroomViewModels(
room: ChatroomKey, room: ChatroomKey,
draftMessage: String?, draftMessage: String?,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -369,29 +373,46 @@ fun PrepareChatroomViewModels(
), ),
) )
val newPostModel: NewPostViewModel = viewModel() val newPostModel: ChatNewMessageViewModel = viewModel()
newPostModel.accountViewModel = accountViewModel newPostModel.init(accountViewModel)
newPostModel.account = accountViewModel.account newPostModel.load(room)
newPostModel.requiresNIP17 = room.users.size > 1
if (newPostModel.requiresNIP17) { if (replyToNote != null) {
newPostModel.nip17 = true LaunchedEffect(key1 = replyToNote) {
} else { accountViewModel.checkGetOrCreateNote(replyToNote) {
if (room.users.size == 1) { if (it != null) {
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) { newPostModel.reply(it)
if (it?.relays().isNullOrEmpty()) {
newPostModel.nip17 = false
} else {
newPostModel.nip17 = true
} }
} }
} }
} }
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() val imageUpload: ChatFileUploadModel = viewModel()
if (draftMessage != null) { if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) { newPostModel.updateMessage(TextFieldValue(draftMessage)) } LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
}
} }
ChatroomScreen( ChatroomScreen(
@@ -408,7 +429,7 @@ fun PrepareChatroomViewModels(
fun ChatroomScreen( fun ChatroomScreen(
room: ChatroomKey, room: ChatroomKey,
feedViewModel: NostrChatroomFeedViewModel, feedViewModel: NostrChatroomFeedViewModel,
newPostModel: NewPostViewModel, newPostModel: ChatNewMessageViewModel,
fileUpload: ChatFileUploadModel, fileUpload: ChatFileUploadModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
@@ -444,7 +465,6 @@ fun ChatroomScreen(
} }
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
val replyTo = remember { mutableStateOf<Note?>(null) }
ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav) ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav)
Column( Column(
@@ -460,18 +480,14 @@ fun ChatroomScreen(
nav = nav, nav = nav,
routeForLastRead = "Room/${room.hashCode()}", routeForLastRead = "Room/${room.hashCode()}",
avoidDraft = newPostModel.draftTag, avoidDraft = newPostModel.draftTag,
onWantsToReply = { onWantsToReply = newPostModel::reply,
replyTo.value = it onWantsToEditDraft = newPostModel::editFromDraft,
},
onWantsToEditDraft = {
newPostModel.load(accountViewModel, null, null, null, null, it)
},
) )
} }
Spacer(modifier = Modifier.height(10.dp)) 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() val scope = rememberCoroutineScope()
@@ -481,7 +497,7 @@ fun ChatroomScreen(
.receiveAsFlow() .receiveAsFlow()
.debounce(1000) .debounce(1000)
.collectLatest { .collectLatest {
innerSendPost(newPostModel, room, replyTo, accountViewModel, newPostModel.draftTag) newPostModel.sendDraft()
} }
} }
} }
@@ -501,85 +517,29 @@ fun ChatroomScreen(
accountViewModel, accountViewModel,
onSendNewMessage = { onSendNewMessage = {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
innerSendPost(newPostModel, room, replyTo, accountViewModel, null) newPostModel.sendPostSync()
accountViewModel.deleteDraft(newPostModel.draftTag)
newPostModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.sendToTop() feedViewModel.sendToTop()
} }
}, },
onSendNewMedia = { onSendNewMedia = { list, isNip17 ->
fileUpload.load(it, room, accountViewModel.account) fileUpload.load(list, room, isNip17, accountViewModel.account)
}, },
) )
} }
} }
private fun innerSendPost(
newPostModel: NewPostViewModel,
room: ChatroomKey,
replyTo: MutableState<Note?>,
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<ChatMessageEvent>()
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 @Composable
fun PrivateMessageEditFieldRow( fun PrivateMessageEditFieldRow(
channelScreenModel: NewPostViewModel, channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit, onSendNewMessage: () -> Unit,
onSendNewMedia: (ImmutableList<SelectedMedia>) -> Unit, onSendNewMedia: (ImmutableList<SelectedMedia>, isNip17: Boolean) -> Unit,
) { ) {
Column( Column(
modifier = EditFieldModifier, modifier = EditFieldModifier,
) { ) {
ShowUserSuggestionList( ShowUserSuggestionList(
channelScreenModel.userSuggestions, channelScreenModel.userSuggestions.userSuggestions,
channelScreenModel::autocompleteWithUser, channelScreenModel::autocompleteWithUser,
accountViewModel, accountViewModel,
) )
@@ -623,11 +583,10 @@ fun PrivateMessageEditFieldRow(
SelectFromGallery( SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage, isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText, tint = MaterialTheme.colorScheme.placeholderText,
modifier = modifier = Modifier.size(30.dp).padding(start = 2.dp),
Modifier onImageChosen = {
.size(30.dp) onSendNewMedia(it, channelScreenModel.nip17)
.padding(start = 2.dp), },
onImageChosen = onSendNewMedia,
) )
var wantsToActivateNIP17 by remember { mutableStateOf(false) } var wantsToActivateNIP17 by remember { mutableStateOf(false) }
@@ -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<List<IMetaTag>>(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<String>) = iMetaAttachments.filter { it.url !in urls }
}
@@ -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<List<User>>(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),
)
}
}
@@ -18,19 +18,14 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.background
import androidx.compose.foundation.clickable 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.EditNote
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -71,25 +56,19 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue 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.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold 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.home.CrossfadeCheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes 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 @Composable
fun EditFieldRow( fun EditFieldRow(
channelScreenModel: NewPostViewModel, 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 @Composable
fun RenderChannelHeader( fun RenderChannelHeader(
channelNote: Note, channelNote: Note,
@@ -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,
)
}
}
}
}
}
@@ -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,
)
},
)
}
}
@@ -50,8 +50,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable @Composable
fun DisplayLNAddress( fun DisplayLNAddress(
@@ -71,9 +69,8 @@ fun DisplayLNAddress(
title = stringRes(id = R.string.error_dialog_zap_error), title = stringRes(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "", textContent = showErrorMessageDialog ?: "",
onClickStartMessage = { onClickStartMessage = {
scope.launch(Dispatchers.IO) { nav.nav {
val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel) routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel)
nav.nav(route)
} }
}, },
onDismiss = { showErrorMessageDialog = null }, onDismiss = { showErrorMessageDialog = null },
@@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -52,7 +52,6 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.private.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -213,7 +212,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -858,7 +856,6 @@ private fun RenderClassifiedsReaderForThread(
} }
var message by remember { mutableStateOf(TextFieldValue(msg)) } var message by remember { mutableStateOf(TextFieldValue(msg)) }
val scope = rememberCoroutineScope()
TextField( TextField(
value = message, value = message,
@@ -881,9 +878,13 @@ private fun RenderClassifiedsReaderForThread(
isActive = message.text.isNotBlank(), isActive = message.text.isNotBlank(),
modifier = EditFieldTrailingIconModifier, modifier = EditFieldTrailingIconModifier,
) { ) {
scope.launch(Dispatchers.IO) { note.author?.let {
note.author?.let { nav.nav {
nav.nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel)) routeToMessage(
it,
note.toNostrUri() + "\n\n" + msg,
accountViewModel = accountViewModel,
)
} }
} }
} }
@@ -266,7 +266,7 @@ val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape)
val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp) val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp)
val messageDetailsModifier = Modifier.height(Size25dp) 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 = val inlinePlaceholder =
Placeholder( Placeholder(
@@ -119,6 +119,9 @@ private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.05f)
private val DarkChatBackground = DarkColorPalette.onSurface.copy(alpha = 0.12f) private val DarkChatBackground = DarkColorPalette.onSurface.copy(alpha = 0.12f)
private val LightChatBackground = LightColorPalette.onSurface.copy(alpha = 0.08f) 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 DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f)
private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f) private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f)
@@ -387,6 +390,9 @@ val ColorScheme.subtleBorder: Color
val ColorScheme.chatBackground: Color val ColorScheme.chatBackground: Color
get() = if (isLight) LightChatBackground else DarkChatBackground get() = if (isLight) LightChatBackground else DarkChatBackground
val ColorScheme.chatDraftBackground: Color
get() = if (isLight) LightChatDraftBackground else DarkChatDraftBackground
val ColorScheme.subtleButton: Color val ColorScheme.subtleButton: Color
get() = if (isLight) LightSubtleButton else DarkSubtleButton get() = if (isLight) LightSubtleButton else DarkSubtleButton
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.compose
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import kotlin.math.max
import kotlin.math.min
fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue { fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue {
var toInsert = url.trim() var toInsert = url.trim()
@@ -41,3 +43,54 @@ fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue {
TextRange(endOfUrlIndex, endOfUrlIndex), 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 {
""
}
}
@@ -50,7 +50,7 @@ data class Address(
fun parse(addressId: String): Address? = fun parse(addressId: String): Address? =
try { try {
val parts = addressId.split(":", limit = 3) 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]) Address(parts[0].toInt(), parts[1], parts[2])
} else { } else {
Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId") Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId")
@@ -60,11 +60,12 @@ class ChatMessageEvent(
fun reply( fun reply(
msg: String, msg: String,
reply: EventHintBundle<ChatMessageEvent>, reply: EventHintBundle<BaseDMGroupEvent>,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ChatMessageEvent>.() -> Unit = {}, initializer: TagArrayBuilder<ChatMessageEvent>.() -> Unit = {},
) = eventTemplate(KIND, msg, createdAt) { ) = eventTemplate(KIND, msg, createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
reply(reply)
group((reply.event.recipients() + reply.toPTag()).distinctBy { it.pubKey }) group((reply.event.recipients() + reply.toPTag()).distinctBy { it.pubKey })
initializer() initializer()
} }
@@ -27,10 +27,11 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTags import com.vitorpamplona.quartz.nip01Core.tags.people.pTags
import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag
import com.vitorpamplona.quartz.nip14Subject.SubjectTag import com.vitorpamplona.quartz.nip14Subject.SubjectTag
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
fun TagArrayBuilder<ChatMessageEvent>.reply(msg: MarkedETag) = add(msg.toTagArray()) fun TagArrayBuilder<ChatMessageEvent>.reply(msg: MarkedETag) = add(msg.toTagArray())
fun TagArrayBuilder<ChatMessageEvent>.reply(msg: EventHintBundle<ChatMessageEvent>) = reply(msg.toMarkedETag(MarkedETag.MARKER.REPLY)) fun TagArrayBuilder<ChatMessageEvent>.reply(msg: EventHintBundle<BaseDMGroupEvent>) = reply(msg.toMarkedETag(MarkedETag.MARKER.REPLY))
fun TagArrayBuilder<ChatMessageEvent>.group(list: List<PTag>) = pTags(list) fun TagArrayBuilder<ChatMessageEvent>.group(list: List<PTag>) = pTags(list)
@@ -29,13 +29,15 @@ data class EmojiUrlTag(
) { ) {
fun encode(): String = ":$code:$url" fun encode(): String = ":$code:$url"
fun toContentEncode(): String = ":$code" fun toContentEncode(): String = contentEncode(code)
fun toTagArray() = arrayOf("emoji", code, url) fun toTagArray() = arrayOf("emoji", code, url)
companion object { companion object {
const val TAG_NAME = "emoji" const val TAG_NAME = "emoji"
fun contentEncode(code: String): String = ":$code:"
fun decode(encodedEmojiSetup: String): EmojiUrlTag? { fun decode(encodedEmojiSetup: String): EmojiUrlTag? {
val emojiParts = encodedEmojiSetup.split(":", limit = 3) val emojiParts = encodedEmojiSetup.split(":", limit = 3)
return if (emojiParts.size > 2) { return if (emojiParts.size > 2) {
@@ -35,3 +35,8 @@ class IMetaTagBuilder(
fun build() = IMetaTag(url, properties) fun build() = IMetaTag(url, properties)
} }
fun imetaTagBuilder(
url: String,
init: IMetaTagBuilder.() -> Unit,
) = IMetaTagBuilder(url).apply(init).build()