- 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,
toUser: User,
replyingTo: Note? = null,
mentions: List<User>?,
zapReceiver: List<ZapSplitSetup>? = null,
contentWarningReason: String? = null,
zapRaiserAmount: Long? = null,
@@ -2590,7 +2589,6 @@ class Account(
message,
toUser.toPTag(),
replyingTo,
mentions,
zapReceiver,
contentWarningReason,
zapRaiserAmount,
@@ -2605,7 +2603,6 @@ class Account(
message: String,
toUser: PTag,
replyingTo: Note? = null,
mentions: List<User>?,
zapReceiver: List<ZapSplitSetup>? = null,
contentWarningReason: String? = null,
zapRaiserAmount: Long? = null,
@@ -3183,10 +3180,16 @@ class Account(
fun cachedDecryptContent(event: Event?): String? {
if (event == null) return null
return if (event is PrivateDmEvent && isWriteable()) {
event.cachedContentFor(signer)
} else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) {
event.cachedPrivateZap()?.content
return if (isWriteable()) {
if (event is PrivateDmEvent) {
event.cachedContentFor(signer)
} else if (event is LnZapRequestEvent && event.isPrivateZap()) {
event.cachedPrivateZap()?.content
} else if (event is DraftEvent) {
event.preCachedDraft(signer)?.content
} else {
event.content
}
} else {
event.content
}
@@ -236,16 +236,13 @@ class User(
}.flatten()
@Synchronized
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom {
checkNotInMainThread()
return privateChatrooms[key]
private fun getOrCreatePrivateChatroomSync(key: ChatroomKey): Chatroom =
privateChatrooms[key]
?: run {
val privateChatroom = Chatroom()
privateChatrooms = privateChatrooms + Pair(key, privateChatroom)
privateChatroom
}
}
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
val key = ChatroomKey(persistentSetOf(user.pubkeyHex))
@@ -81,7 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchBarViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -86,6 +86,7 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip10Notes.tags.notify
import com.vitorpamplona.quartz.nip10Notes.tags.positionalMarkedTags
import com.vitorpamplona.quartz.nip14Subject.subject
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject
@@ -818,7 +819,6 @@ open class NewPostViewModel : ViewModel() {
message = tagger.message,
toUser = originalNote!!.author!!,
replyingTo = originalNote!!,
mentions = tagger.pTags,
zapReceiver = zapReceiver,
contentWarningReason = contentWarningReason,
zapRaiserAmount = localZapRaiserAmount,
@@ -827,7 +827,7 @@ open class NewPostViewModel : ViewModel() {
draftTag = localDraft,
)
} else if (originalNote?.event is NIP17Group) {
val replyHint = originalNote?.toEventHint<ChatMessageEvent>()
val replyHint = originalNote?.toEventHint<BaseDMGroupEvent>()
val template =
if (replyHint == null) {
@@ -868,7 +868,7 @@ open class NewPostViewModel : ViewModel() {
account?.sendNIP17PrivateMessage(template, localDraft)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip17 || dmUsers.size > 1) {
val replyHint = originalNote?.toEventHint<ChatMessageEvent>()
val replyHint = originalNote?.toEventHint<BaseDMGroupEvent>()
val template =
if (replyHint == null) {
ChatMessageEvent.build(tagger.message, dmUsers.map { it.toPTag() }) {
@@ -910,7 +910,6 @@ open class NewPostViewModel : ViewModel() {
message = tagger.message,
toUser = dmUsers.first(),
replyingTo = originalNote,
mentions = tagger.pTags,
contentWarningReason = contentWarningReason,
zapReceiver = zapReceiver,
zapRaiserAmount = localZapRaiserAmount,
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -91,7 +91,7 @@ import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
@@ -41,7 +41,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NewItemsBubble
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.NewItemsBubble
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -60,10 +60,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NewPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.ChatroomListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
@@ -94,6 +94,16 @@ fun NavBackStackEntry.message(): String? =
URLDecoder.decode(it, "utf-8")
}
fun NavBackStackEntry.replyId(): String? =
arguments?.getString("replyId")?.let {
URLDecoder.decode(it, "utf-8")
}
fun NavBackStackEntry.draftId(): String? =
arguments?.getString("draftId")?.let {
URLDecoder.decode(it, "utf-8")
}
@Composable
fun AppNavigation(
accountViewModel: AccountViewModel,
@@ -219,6 +229,8 @@ fun AppNavigation(
ChatroomScreen(
roomId = it.id(),
draftMessage = it.message(),
replyToNote = it.replyId(),
editFromDraft = it.draftId(),
accountViewModel = accountViewModel,
nav = nav,
)
@@ -54,6 +54,8 @@ interface INav {
fun nav(route: String)
fun nav(computeRoute: suspend () -> String)
fun newStack(route: String)
fun popBack()
@@ -91,6 +93,15 @@ class Nav(
}
}
override fun nav(computeRoute: suspend () -> String) {
scope.launch {
val route = computeRoute()
if (getRouteWithArguments(controller) != route) {
controller.navigate(route)
}
}
}
override fun newStack(route: String) {
scope.launch {
controller.navigate(route) {
@@ -135,6 +146,9 @@ object EmptyNav : INav {
override fun nav(route: String) {
}
override fun nav(computeRoute: suspend () -> String) {
}
override fun newStack(route: String) {
}
@@ -161,6 +175,11 @@ class ObservableNavigate(
nav.nav(route)
}
override fun nav(computeRoute: suspend () -> String) {
onNavigate()
nav.nav(computeRoute)
}
override fun newStack(route: String) {
onNavigate()
nav.newStack(route)
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.ui.navigation
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache.users
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -36,7 +38,6 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import kotlinx.collections.immutable.persistentSetOf
import java.net.URLEncoder
fun routeFor(
@@ -110,23 +111,71 @@ fun routeFor(
fun routeToMessage(
user: HexKey,
draftMessage: String?,
replyId: HexKey? = null,
quoteId: HexKey? = null,
accountViewModel: AccountViewModel,
): String =
routeToMessage(
setOf(user),
draftMessage,
replyId,
quoteId,
accountViewModel,
)
fun routeToMessage(
users: Set<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,
): String {
val withKey = ChatroomKey(persistentSetOf(user))
accountViewModel.account.userProfile().createChatroom(withKey)
return if (draftMessage != null) {
val encodedMessage = URLEncoder.encode(draftMessage, "utf-8")
"Room/${withKey.hashCode()}?message=$encodedMessage"
} else {
"Room/${withKey.hashCode()}"
accountViewModel.account.userProfile().createChatroom(room)
val params =
listOfNotNull(
draftMessage?.let {
"message=${URLEncoder.encode(it, "utf-8")}"
},
replyId?.let {
"replyId=${URLEncoder.encode(it, "utf-8")}"
},
quoteId?.let {
"quoteId=${URLEncoder.encode(it, "utf-8")}"
},
)
return buildString {
append("Room/")
append(room.hashCode().toString())
if (params.isNotEmpty()) {
append("?")
append(params.joinToString("&"))
}
}
}
fun routeToMessage(
user: User,
draftMessage: String?,
replyId: HexKey? = null,
quoteId: HexKey? = null,
accountViewModel: AccountViewModel,
): String = routeToMessage(user.pubkeyHex, draftMessage, accountViewModel)
): String = routeToMessage(user.pubkeyHex, draftMessage, replyId, quoteId, accountViewModel)
fun routeFor(note: Channel): String = "Channel/${note.idHex}"
@@ -169,7 +169,7 @@ sealed class Route(
object Room :
Route(
route = "Room/{id}?message={message}",
route = "Room/{id}?message={message}&replyId={replyId}&draftId={draftId}",
icon = R.drawable.ic_moments,
arguments =
listOf(
@@ -179,6 +179,16 @@ sealed class Route(
nullable = true
defaultValue = null
},
navArgument("replyId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("draftId") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
)
@@ -78,11 +78,11 @@ import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.elements.BannerImage
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.EndedFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.OfflineFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
@@ -25,7 +25,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@@ -73,15 +72,15 @@ fun LoadDecryptedContentOrNull(
accountViewModel: AccountViewModel,
inner: @Composable (String?) -> Unit,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val decryptedContent by
produceState(initialValue = accountViewModel.cachedDecrypt(note), key1 = note.event?.id) {
accountViewModel.decrypt(note) {
if (value != it) {
value = it
}
var decryptedContent by remember(note.event?.id) { mutableStateOf(accountViewModel.cachedDecrypt(note)) }
LaunchedEffect(note.event?.id) {
accountViewModel.decrypt(note) {
if (decryptedContent != it) {
decryptedContent = it
}
}
}
inner(decryptedContent)
}
@@ -43,7 +43,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -74,7 +73,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@@ -202,15 +200,14 @@ fun ErrorRow(
horizontalArrangement = Arrangement.SpaceBetween,
) {
errorState.user?.let {
val scope = rememberCoroutineScope()
Column(Modifier.width(Size40dp), horizontalAlignment = Alignment.Start) {
UserPicture(errorState.user, Size30dp, Modifier, accountViewModel, nav)
Spacer(StdVertSpacer)
IconButton(
modifier = Size30Modifier,
onClick = {
scope.launch(Dispatchers.IO) {
nav.nav(routeToMessage(it, errorState.error, accountViewModel))
nav.nav {
routeToMessage(it, errorState.error, accountViewModel = accountViewModel)
}
},
) {
@@ -123,12 +123,11 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment
import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.RenderChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.RenderChannelHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp
@@ -1022,7 +1021,7 @@ fun DisplayDraftChat() {
Text(
"Draft",
color = MaterialTheme.colorScheme.placeholderText,
modifier = HalfEndPadding,
modifier = Modifier,
fontWeight = FontWeight.Bold,
fontSize = Font12SP,
maxLines = 1,
@@ -627,7 +627,11 @@ fun ZapVote(
title = toast.title,
textContent = toast.msg,
onClickStartMessage = {
baseNote.author?.let { nav.nav(routeToMessage(it, toast.msg, accountViewModel)) }
baseNote.author?.let {
nav.nav {
routeToMessage(it, toast.msg, accountViewModel = accountViewModel)
}
}
},
onDismiss = { showErrorMessageDialog = null },
)
@@ -111,6 +111,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -147,7 +148,9 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import kotlinx.collections.immutable.ImmutableList
@@ -209,13 +212,16 @@ private fun InnerReactionRow(
)
},
three = {
BoostWithDialog(
baseNote,
editState,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
)
val isDM = baseNote.event is ChatroomKeyable
if (!isDM) {
BoostWithDialog(
baseNote,
editState,
MaterialTheme.colorScheme.placeholderText,
accountViewModel,
nav,
)
}
},
four = {
LikeReaction(baseNote, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
@@ -558,56 +564,46 @@ private fun BoostWithDialog(
accountViewModel: AccountViewModel,
nav: INav,
) {
var wantsToQuote by remember { mutableStateOf<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(
baseNote,
grayTint,
accountViewModel,
onQuotePress = { wantsToQuote = baseNote },
onForkPress = { wantsToFork = baseNote },
onQuotePress = {
nav.nav {
buildNewPostRoute(
quote = baseNote.idHex,
version =
(editState.value as? GenericLoadable.Loaded)
?.loaded
?.modificationToShow
?.value
?.idHex,
)
}
},
onForkPress = {
nav.nav {
val forkEvent = baseNote.event
val replyTo =
if (forkEvent is BaseThreadedEvent) {
val hex = forkEvent.replyingTo()
baseNote.replyTo?.filter { it.event?.id == hex }?.firstOrNull()
} else {
null
}
buildNewPostRoute(
baseReplyTo = replyTo?.idHex,
fork = baseNote.idHex,
version =
(editState.value as? GenericLoadable.Loaded)
?.loaded
?.modificationToShow
?.value
?.idHex,
)
}
},
)
}
@@ -618,18 +614,37 @@ private fun ReplyReactionWithDialog(
accountViewModel: AccountViewModel,
nav: INav,
) {
var wantsToReplyTo by remember { mutableStateOf<Note?>(null) }
if (wantsToReplyTo != null) {
val route =
buildNewPostRoute(
baseReplyTo = wantsToReplyTo?.idHex,
quote = null,
)
nav.nav(route)
ReplyReaction(baseNote, grayTint, accountViewModel) {
val noteEvent = baseNote.event
if (noteEvent is PrivateDmEvent) {
nav.nav {
routeToMessage(
room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex),
draftMessage = null,
replyId = noteEvent.id,
quoteId = null,
accountViewModel = accountViewModel,
)
}
} else if (noteEvent is ChatroomKeyable) {
nav.nav {
routeToMessage(
room = noteEvent.chatroomKey(accountViewModel.userProfile().pubkeyHex),
draftMessage = null,
replyId = noteEvent.id,
quoteId = null,
accountViewModel = accountViewModel,
)
}
} else {
nav.nav {
buildNewPostRoute(
baseReplyTo = baseNote.idHex,
quote = null,
)
}
}
}
ReplyReaction(baseNote, grayTint, accountViewModel) { wantsToReplyTo = baseNote }
}
@Composable
@@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
@@ -36,7 +36,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -49,7 +49,7 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@@ -58,7 +58,6 @@ import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip31Alts.alt
import kotlinx.collections.immutable.persistentListOf
@@ -61,9 +61,9 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.JoinCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LeaveCommunityButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.NormalTimeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -58,8 +58,8 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.LiveFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ScheduledFlag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
@@ -43,7 +43,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChatroomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ChatroomHeader
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -1666,6 +1666,8 @@ class AccountViewModel(
}
}
suspend fun findUsersStartingWithSync(prefix: String) = LocalCache.findUsersStartingWith(prefix, account)
fun relayStatusFlow() = Amethyst.instance.client.relayStatusFlow()
val draftNoteCache = CachedDraftNotes(this)
@@ -162,7 +162,7 @@ import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.note.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.ZapSplitIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Row
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
@@ -75,10 +75,13 @@ import com.vitorpamplona.amethyst.ui.navigation.MainTopBar
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.Chatroom
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.Channel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -121,6 +124,7 @@ fun ChatroomListScreen(
class TwoPaneNav(
val nav: INav,
val scope: CoroutineScope,
) : INav {
override val drawerState: DrawerState = nav.drawerState
@@ -134,6 +138,17 @@ class TwoPaneNav(
}
}
override fun nav(routeMaker: suspend () -> String) {
scope.launch(Dispatchers.Default) {
val route = routeMaker()
if (route.startsWith("Room/") || route.startsWith("Channel/")) {
innerNav.value = RouteId(route.substringBefore("/"), route.substringAfter("/"))
} else {
nav.nav(route)
}
}
}
override fun newStack(route: String) {
nav.newStack(route)
}
@@ -172,7 +187,8 @@ fun ChatroomListTwoPane(
nav: INav,
) {
/** The index of the currently selected word, or `null` if none is selected */
val twoPaneNav = remember { TwoPaneNav(nav) }
val scope = rememberCoroutineScope()
val twoPaneNav = remember { TwoPaneNav(nav, scope) }
val strategy =
remember {
@@ -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
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private
import android.content.Context
import androidx.compose.runtime.Stable
@@ -40,9 +40,11 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.files.encryption.AESGCM
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import kotlinx.collections.immutable.ImmutableList
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
open class ChatFileUploadModel : ViewModel() {
var account: Account? = null
var chatroom: ChatroomKey? = null
var isNip17: Boolean = false
var isUploadingImage by mutableStateOf(false)
@@ -69,6 +72,7 @@ open class ChatFileUploadModel : ViewModel() {
open fun load(
uris: ImmutableList<SelectedMedia>,
chatroom: ChatroomKey,
isNip17: Boolean,
account: Account,
) {
this.chatroom = chatroom
@@ -76,6 +80,7 @@ open class ChatFileUploadModel : ViewModel() {
this.account = account
this.multiOrchestrator = MultiOrchestrator(uris)
this.selectedServer = defaultServer()
this.isNip17 = isNip17
}
fun isImage(
@@ -87,6 +92,18 @@ open class ChatFileUploadModel : ViewModel() {
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
if (isNip17) {
uploadNIP17(onError, context, onceUploaded)
} else {
uploadNIP04(onError, context, onceUploaded)
}
}
fun uploadNIP17(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
val myAccount = account ?: return
val mySelectedServer = selectedServer ?: return
@@ -152,6 +169,65 @@ open class ChatFileUploadModel : ViewModel() {
}
}
fun uploadNIP04(
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: () -> Unit,
) {
viewModelScope.launch(Dispatchers.Default) {
val myAccount = account ?: return@launch
val myMultiOrchestrator = multiOrchestrator ?: return@launch
val mySelectedServer = selectedServer ?: return@launch
val myChatroom = chatroom ?: return@launch
isUploadingImage = true
val results =
myMultiOrchestrator.upload(
viewModelScope,
caption,
if (sensitiveContent) "" else null,
MediaCompressor.intToCompressorQuality(mediaQualitySlider),
mySelectedServer,
myAccount,
context,
)
if (results.allGood) {
results.successful.forEach {
if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
val iMetaAttachments = IMetaAttachments()
iMetaAttachments.add(it.result, caption, if (sensitiveContent) "" else null)
myAccount.sendPrivateMessage(
message = it.result.url,
toUser = myChatroom.users.first().let { LocalCache.getOrCreateUser(it).toPTag() },
replyingTo = null,
zapReceiver = null,
contentWarningReason = null,
zapRaiserAmount = null,
geohash = null,
imetas = iMetaAttachments.iMetaAttachments,
emojis = null,
draftTag = null,
)
}
onceUploaded()
cancelModel()
}
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
isUploadingImage = false
}
}
open fun cancelModel() {
multiOrchestrator = null
isUploadingImage = false
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -148,9 +148,8 @@ fun ChatFileUploadView(
postViewModel.upload(
onError = accountViewModel::toast,
context = context,
) {
onClose
}
onceUploaded = onClose,
)
postViewModel.selectedServer?.let {
if (it.type != ServerType.NIP95) {
@@ -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
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Row
@@ -18,25 +18,16 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -44,17 +35,13 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
@@ -80,26 +67,15 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.note.types.RenderEncryptedFile
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleMaxSizeModifier
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeMe
import com.vitorpamplona.amethyst.ui.theme.ChatBubbleShapeThem
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingInnerQuoteModifier
import com.vitorpamplona.amethyst.ui.theme.ChatPaddingModifier
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.ReactionRowHeightChat
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5Modifier
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox
import com.vitorpamplona.amethyst.ui.theme.chatBackground
import com.vitorpamplona.amethyst.ui.theme.incognitoIconModifier
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.messageBubbleLimits
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList
import com.vitorpamplona.quartz.nip02FollowList.ImmutableListOfLists
@@ -188,6 +164,7 @@ fun NormalChatNote(
ChatBubbleLayout(
isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftEvent,
innerQuote = innerQuote,
isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE,
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
@@ -223,27 +200,29 @@ fun NormalChatNote(
)
},
detailRow = {
if (note.isDraft()) {
DisplayDraftChat()
}
IncognitoBadge(note)
ChatTimeAgo(note)
RelayBadgesHorizontal(note, accountViewModel, nav = nav)
Spacer(modifier = DoubleHorzSpacer)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) {
ReplyReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSizeModifier = Size18Modifier,
) {
onWantsToReply(note)
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
if (!note.isDraft()) {
ReplyReaction(
baseNote = note,
grayTint = MaterialTheme.colorScheme.placeholderText,
accountViewModel = accountViewModel,
showCounter = false,
iconSizeModifier = Size18Modifier,
) {
onWantsToReply(note)
}
Spacer(modifier = StdHorzSpacer)
LikeReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav)
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
ZapReaction(note, MaterialTheme.colorScheme.placeholderText, accountViewModel, nav = nav)
} else {
DisplayDraftChat()
}
}
},
) { backgroundBubbleColor ->
@@ -260,221 +239,6 @@ fun NormalChatNote(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatBubbleLayout(
isLoggedInUser: Boolean,
innerQuote: Boolean,
isComplete: Boolean,
hasDetailsToShow: Boolean,
drawAuthorInfo: Boolean,
parentBackgroundColor: MutableState<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
private fun MessageBubbleLines(
baseNote: Note,
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -53,7 +53,6 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -80,11 +79,9 @@ import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
@@ -106,6 +103,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.DisplayRoomSubject
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.DisplayUserSetAsSubject
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.MyTextField
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -119,18 +121,10 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.messages.changeSubject
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip92IMeta.imetas
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
@@ -144,6 +138,8 @@ import kotlinx.coroutines.launch
fun ChatroomScreen(
roomId: String?,
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -157,7 +153,7 @@ fun ChatroomScreen(
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
Chatroom(roomId, draftMessage, accountViewModel, nav)
Chatroom(roomId, draftMessage, replyToNote, editFromDraft, accountViewModel, nav)
}
}
}
@@ -238,6 +234,8 @@ private fun RenderRoomTopBar(
fun Chatroom(
roomId: String?,
draftMessage: String? = null,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -248,6 +246,8 @@ fun Chatroom(
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
replyToNote = replyToNote,
editFromDraft = editFromDraft,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -306,6 +306,8 @@ fun ChatroomByAuthor(
PrepareChatroomViewModels(
room = it,
draftMessage = draftMessage,
replyToNote = null,
editFromDraft = null,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -356,6 +358,8 @@ fun LoadRoomByAuthor(
fun PrepareChatroomViewModels(
room: ChatroomKey,
draftMessage: String?,
replyToNote: HexKey? = null,
editFromDraft: HexKey? = null,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -369,29 +373,46 @@ fun PrepareChatroomViewModels(
),
)
val newPostModel: NewPostViewModel = viewModel()
newPostModel.accountViewModel = accountViewModel
newPostModel.account = accountViewModel.account
newPostModel.requiresNIP17 = room.users.size > 1
val newPostModel: ChatNewMessageViewModel = viewModel()
newPostModel.init(accountViewModel)
newPostModel.load(room)
if (newPostModel.requiresNIP17) {
newPostModel.nip17 = true
} else {
if (room.users.size == 1) {
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
if (it?.relays().isNullOrEmpty()) {
newPostModel.nip17 = false
} else {
newPostModel.nip17 = true
if (replyToNote != null) {
LaunchedEffect(key1 = replyToNote) {
accountViewModel.checkGetOrCreateNote(replyToNote) {
if (it != null) {
newPostModel.reply(it)
}
}
}
}
if (editFromDraft != null) {
LaunchedEffect(key1 = replyToNote) {
accountViewModel.checkGetOrCreateNote(editFromDraft) {
if (it != null) {
newPostModel.editFromDraft(it)
}
}
}
}
if (room.users.size == 1) {
// Activates NIP-17 if the user has DM relays
ObserveRelayListForDMs(pubkey = room.users.first(), accountViewModel = accountViewModel) {
if (it?.relays().isNullOrEmpty()) {
newPostModel.nip17 = false
} else {
newPostModel.nip17 = true
}
}
}
val imageUpload: ChatFileUploadModel = viewModel()
if (draftMessage != null) {
LaunchedEffect(key1 = draftMessage) { newPostModel.updateMessage(TextFieldValue(draftMessage)) }
LaunchedEffect(key1 = draftMessage) {
newPostModel.updateMessage(TextFieldValue(draftMessage))
}
}
ChatroomScreen(
@@ -408,7 +429,7 @@ fun PrepareChatroomViewModels(
fun ChatroomScreen(
room: ChatroomKey,
feedViewModel: NostrChatroomFeedViewModel,
newPostModel: NewPostViewModel,
newPostModel: ChatNewMessageViewModel,
fileUpload: ChatFileUploadModel,
accountViewModel: AccountViewModel,
nav: INav,
@@ -444,7 +465,6 @@ fun ChatroomScreen(
}
Column(Modifier.fillMaxHeight()) {
val replyTo = remember { mutableStateOf<Note?>(null) }
ObserveRelayListForDMsAndDisplayIfNotFound(accountViewModel, nav)
Column(
@@ -460,18 +480,14 @@ fun ChatroomScreen(
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = {
replyTo.value = it
},
onWantsToEditDraft = {
newPostModel.load(accountViewModel, null, null, null, null, it)
},
onWantsToReply = newPostModel::reply,
onWantsToEditDraft = newPostModel::editFromDraft,
)
}
Spacer(modifier = Modifier.height(10.dp))
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } }
newPostModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { newPostModel.replyTo.value = null } }
val scope = rememberCoroutineScope()
@@ -481,7 +497,7 @@ fun ChatroomScreen(
.receiveAsFlow()
.debounce(1000)
.collectLatest {
innerSendPost(newPostModel, room, replyTo, accountViewModel, newPostModel.draftTag)
newPostModel.sendDraft()
}
}
}
@@ -501,85 +517,29 @@ fun ChatroomScreen(
accountViewModel,
onSendNewMessage = {
scope.launch(Dispatchers.IO) {
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
accountViewModel.deleteDraft(newPostModel.draftTag)
newPostModel.message = TextFieldValue("")
replyTo.value = null
newPostModel.sendPostSync()
feedViewModel.sendToTop()
}
},
onSendNewMedia = {
fileUpload.load(it, room, accountViewModel.account)
onSendNewMedia = { list, isNip17 ->
fileUpload.load(list, room, isNip17, accountViewModel.account)
},
)
}
}
private fun innerSendPost(
newPostModel: NewPostViewModel,
room: ChatroomKey,
replyTo: MutableState<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
fun PrivateMessageEditFieldRow(
channelScreenModel: NewPostViewModel,
channelScreenModel: ChatNewMessageViewModel,
accountViewModel: AccountViewModel,
onSendNewMessage: () -> Unit,
onSendNewMedia: (ImmutableList<SelectedMedia>) -> Unit,
onSendNewMedia: (ImmutableList<SelectedMedia>, isNip17: Boolean) -> Unit,
) {
Column(
modifier = EditFieldModifier,
) {
ShowUserSuggestionList(
channelScreenModel.userSuggestions,
channelScreenModel.userSuggestions.userSuggestions,
channelScreenModel::autocompleteWithUser,
accountViewModel,
)
@@ -623,11 +583,10 @@ fun PrivateMessageEditFieldRow(
SelectFromGallery(
isUploading = channelScreenModel.isUploadingImage,
tint = MaterialTheme.colorScheme.placeholderText,
modifier =
Modifier
.size(30.dp)
.padding(start = 2.dp),
onImageChosen = onSendNewMedia,
modifier = Modifier.size(30.dp).padding(start = 2.dp),
onImageChosen = {
onSendNewMedia(it, channelScreenModel.nip17)
},
)
var wantsToActivateNIP17 by remember { mutableStateOf(false) }
@@ -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
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -38,30 +33,20 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@@ -71,25 +56,19 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -141,6 +120,10 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.RefreshingChatroomFeedView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.DisplayReplyingToNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.utils.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.amethyst.ui.stringRes
@@ -501,52 +484,6 @@ private suspend fun innerSendPost(
}
}
@Composable
fun DisplayReplyingToNote(
replyingNote: Note?,
accountViewModel: AccountViewModel,
nav: INav,
onCancel: () -> Unit,
) {
Row(
Modifier
.padding(horizontal = 10.dp)
.heightIn(max = 100.dp)
.verticalScroll(rememberScrollState())
.animateContentSize(),
) {
if (replyingNote != null) {
Column(remember { Modifier.weight(1f) }) {
ChatroomMessageCompose(
baseNote = replyingNote,
null,
innerQuote = true,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {},
onWantsToEditDraft = {},
)
}
Column(Modifier.padding(start = 5.dp)) {
IconButton(
modifier = Modifier.size(20.dp),
onClick = onCancel,
) {
Icon(
imageVector = Icons.Default.Cancel,
null,
modifier =
Modifier
.size(20.dp),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
}
}
@Composable
fun EditFieldRow(
channelScreenModel: NewPostViewModel,
@@ -624,113 +561,6 @@ fun EditFieldRow(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyTextField(
value: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
readOnly: Boolean = false,
textStyle: TextStyle = LocalTextStyle.current,
label: @Composable (() -> Unit)? = null,
placeholder: @Composable (() -> Unit)? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
prefix: @Composable (() -> Unit)? = null,
suffix: @Composable (() -> Unit)? = null,
supportingText: @Composable (() -> Unit)? = null,
isError: Boolean = false,
visualTransformation: VisualTransformation = VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
singleLine: Boolean = false,
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
minLines: Int = 1,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
shape: Shape = TextFieldDefaults.shape,
colors: TextFieldColors = TextFieldDefaults.colors(),
contentPadding: PaddingValues =
if (label == null) {
TextFieldDefaults.contentPaddingWithoutLabel(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
)
} else {
TextFieldDefaults.contentPaddingWithLabel(
start = 10.dp,
top = 12.dp,
end = 10.dp,
bottom = 12.dp,
)
},
) {
// COPIED FROM TEXT FIELD
// The only change is the contentPadding below
val textColor =
textStyle.color.takeOrElse {
val focused by interactionSource.collectIsFocusedAsState()
val targetValue =
when {
!enabled -> MaterialTheme.colorScheme.placeholderText
isError -> MaterialTheme.colorScheme.onSurface
focused -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurface
}
rememberUpdatedState(targetValue).value
}
val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
CompositionLocalProvider(LocalTextSelectionColors provides LocalTextSelectionColors.current) {
BasicTextField(
value = value,
modifier =
modifier.defaultMinSize(
minWidth = TextFieldDefaults.MinWidth,
minHeight = 36.dp,
),
onValueChange = onValueChange,
enabled = enabled,
readOnly = readOnly,
textStyle = mergedTextStyle,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
interactionSource = interactionSource,
singleLine = singleLine,
maxLines = maxLines,
minLines = minLines,
decorationBox =
@Composable { innerTextField ->
TextFieldDefaults.DecorationBox(
value = value.text,
visualTransformation = visualTransformation,
innerTextField = innerTextField,
placeholder = placeholder,
label = label,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
prefix = prefix,
suffix = suffix,
supportingText = supportingText,
shape = shape,
singleLine = singleLine,
enabled = enabled,
isError = isError,
interactionSource = interactionSource,
colors = colors,
contentPadding = contentPadding,
)
},
)
}
}
@Composable
fun RenderChannelHeader(
channelNote: Note,
@@ -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.quartz.nip47WalletConnect.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun DisplayLNAddress(
@@ -71,9 +69,8 @@ fun DisplayLNAddress(
title = stringRes(id = R.string.error_dialog_zap_error),
textContent = showErrorMessageDialog ?: "",
onClickStartMessage = {
scope.launch(Dispatchers.IO) {
val route = routeToMessage(userHex, showErrorMessageDialog, accountViewModel)
nav.nav(route)
nav.nav {
routeToMessage(userHex, showErrorMessageDialog, accountViewModel = accountViewModel)
}
},
onDismiss = { showErrorMessageDialog = null },
@@ -75,7 +75,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.elements.ObserveRelayListForSearchAndDisplayIfNotFound
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelName
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.ChannelName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -52,7 +52,6 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -148,8 +147,8 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.LevelFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RenderFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.private.ThinSendButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatlist.public.ChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -213,7 +212,6 @@ import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@@ -858,7 +856,6 @@ private fun RenderClassifiedsReaderForThread(
}
var message by remember { mutableStateOf(TextFieldValue(msg)) }
val scope = rememberCoroutineScope()
TextField(
value = message,
@@ -881,9 +878,13 @@ private fun RenderClassifiedsReaderForThread(
isActive = message.text.isNotBlank(),
modifier = EditFieldTrailingIconModifier,
) {
scope.launch(Dispatchers.IO) {
note.author?.let {
nav.nav(routeToMessage(it, note.toNostrUri() + "\n\n" + msg, accountViewModel))
note.author?.let {
nav.nav {
routeToMessage(
it,
note.toNostrUri() + "\n\n" + msg,
accountViewModel = accountViewModel,
)
}
}
}
@@ -266,7 +266,7 @@ val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape)
val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp)
val messageDetailsModifier = Modifier.height(Size25dp)
val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 5.dp)
val messageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 6.dp, bottom = 5.dp)
val inlinePlaceholder =
Placeholder(
@@ -119,6 +119,9 @@ private val LightSubtleBorder = LightColorPalette.onSurface.copy(alpha = 0.05f)
private val DarkChatBackground = DarkColorPalette.onSurface.copy(alpha = 0.12f)
private val LightChatBackground = LightColorPalette.onSurface.copy(alpha = 0.08f)
private val DarkChatDraftBackground = DarkColorPalette.onSurface.copy(alpha = 0.15f)
private val LightChatDraftBackground = LightColorPalette.onSurface.copy(alpha = 0.15f)
private val DarkOverPictureBackground = DarkColorPalette.background.copy(0.62f)
private val LightOverPictureBackground = LightColorPalette.background.copy(0.62f)
@@ -387,6 +390,9 @@ val ColorScheme.subtleBorder: Color
val ColorScheme.chatBackground: Color
get() = if (isLight) LightChatBackground else DarkChatBackground
val ColorScheme.chatDraftBackground: Color
get() = if (isLight) LightChatDraftBackground else DarkChatDraftBackground
val ColorScheme.subtleButton: Color
get() = if (isLight) LightSubtleButton else DarkSubtleButton
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.commons.compose
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import kotlin.math.max
import kotlin.math.min
fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue {
var toInsert = url.trim()
@@ -41,3 +43,54 @@ fun TextFieldValue.insertUrlAtCursor(url: String): TextFieldValue {
TextRange(endOfUrlIndex, endOfUrlIndex),
)
}
fun TextFieldValue.replaceCurrentWord(wordToInsert: String): TextFieldValue {
val lastWordStart = currentWordStartIdx()
val lastWordEnd = currentWordEndIdx()
val cursor = lastWordStart + wordToInsert.length
return TextFieldValue(
text.replaceRange(lastWordStart, lastWordEnd, wordToInsert),
TextRange(cursor, cursor),
)
}
fun TextFieldValue.currentWordStartIdx(): Int {
val previousNewLine = text.lastIndexOf('\n', selection.start - 1)
val previousSpace = text.lastIndexOf(' ', selection.start - 1)
return max(
previousNewLine,
previousSpace,
) + 1
}
fun TextFieldValue.currentWordEndIdx(): Int {
val nextNewLine = text.indexOf('\n', selection.end)
val nextSpace = text.indexOf(' ', selection.end)
if (nextSpace < 0 && nextNewLine < 0) return selection.end
if (nextSpace > 0 && nextNewLine > 0) {
return min(
nextNewLine,
nextSpace,
)
}
if (nextSpace > 0) {
return nextSpace
}
return nextNewLine
}
fun TextFieldValue.currentWord(): String {
if (selection.end != selection.start) return ""
val start = currentWordStartIdx()
val end = currentWordEndIdx()
return if (start < end) {
val word = text.substring(start, end)
word
} else {
""
}
}
@@ -50,7 +50,7 @@ data class Address(
fun parse(addressId: String): Address? =
try {
val parts = addressId.split(":", limit = 3)
if (parts[1].length == 64 && Hex.isHex(parts[1])) {
if (parts.size > 1 && parts[1].length == 64 && Hex.isHex(parts[1])) {
Address(parts[0].toInt(), parts[1], parts[2])
} else {
Log.w("AddressableId", "Error parsing. Pubkey is not hex: $addressId")
@@ -60,11 +60,12 @@ class ChatMessageEvent(
fun reply(
msg: String,
reply: EventHintBundle<ChatMessageEvent>,
reply: EventHintBundle<BaseDMGroupEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<ChatMessageEvent>.() -> Unit = {},
) = eventTemplate(KIND, msg, createdAt) {
alt(ALT_DESCRIPTION)
reply(reply)
group((reply.event.recipients() + reply.toPTag()).distinctBy { it.pubKey })
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.nip10Notes.tags.MarkedETag
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: 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)
@@ -29,13 +29,15 @@ data class EmojiUrlTag(
) {
fun encode(): String = ":$code:$url"
fun toContentEncode(): String = ":$code"
fun toContentEncode(): String = contentEncode(code)
fun toTagArray() = arrayOf("emoji", code, url)
companion object {
const val TAG_NAME = "emoji"
fun contentEncode(code: String): String = ":$code:"
fun decode(encodedEmojiSetup: String): EmojiUrlTag? {
val emojiParts = encodedEmojiSetup.split(":", limit = 3)
return if (emojiParts.size > 2) {
@@ -35,3 +35,8 @@ class IMetaTagBuilder(
fun build() = IMetaTag(url, properties)
}
fun imetaTagBuilder(
url: String,
init: IMetaTagBuilder.() -> Unit,
) = IMetaTagBuilder(url).apply(init).build()