diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index cbdc6873d..d73db1e3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -135,6 +135,7 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEve import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning +import com.vitorpamplona.quartz.nip37Drafts.DraftBuilder import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent @@ -2101,6 +2102,27 @@ class Account( Amethyst.instance.client.send(signedEvent, relayList = relayList) } + fun sendNip95Privately( + data: FileStorageEvent, + signedEvent: FileStorageHeaderEvent, + relayList: List, + ) { + val connect = + relayList.map { + val normalizedUrl = RelayUrlFormatter.normalize(it) + RelaySetupInfoToConnect( + normalizedUrl, + shouldUseTorForClean(normalizedUrl), + true, + true, + setOf(FeedType.GLOBAL), + ) + } + + Amethyst.instance.client.sendPrivately(data, relayList = connect) + Amethyst.instance.client.sendPrivately(signedEvent, relayList = connect) + } + fun sendHeader( signedEvent: Event, relayList: List, @@ -2284,6 +2306,29 @@ class Account( } } + fun signAndSendPrivately( + template: EventTemplate, + relayList: List, + ) { + signer.sign(template) { + LocalCache.justConsume(it, null) + Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relayList)) + } + } + + fun signAndSend( + template: EventTemplate, + relayList: List, + broadcastNotes: Set, + ) { + signer.sign(template) { + LocalCache.justConsume(it, null) + Amethyst.instance.client.send(it, relayList = relayList) + + broadcastNotes.forEach { it.event?.let { Amethyst.instance.client.send(it, relayList = relayList) } } + } + } + fun signAndSend( draftTag: String?, template: EventTemplate, @@ -2357,6 +2402,16 @@ class Account( signAndSend(draftTag, template, relayList, broadcastNotes) } + fun createAndSendDraft( + draftTag: String, + template: EventTemplate, + ) { + val rumor = signer.assembleRumor(template) + DraftBuilder.encryptAndSign(draftTag, rumor, signer) { draftEvent -> + sendDraftEvent(draftEvent) + } + } + fun deleteDraft(draftTag: String) { val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) LocalCache.getAddressableNoteIfExists(key)?.let { note -> @@ -2680,6 +2735,18 @@ class Account( LocalCache.justConsume(draftEvent, null) } + fun convertRelayList(broadcast: List): List = + broadcast.map { + val normalizedUrl = RelayUrlFormatter.normalize(it) + RelaySetupInfoToConnect( + normalizedUrl, + shouldUseTorForClean(normalizedUrl), + true, + true, + setOf(FeedType.GLOBAL), + ) + } + fun broadcastPrivately(signedEvents: NIP17Factory.Result) { val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 3abeb310d..1c9c97fa0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2399,9 +2399,12 @@ object LocalCache { } is ChannelMessageEvent -> { draft.channelId()?.let { channelId -> - checkGetOrCreateChannel(channelId)?.let { channel -> - channel.addNote(note, null) - } + checkGetOrCreateChannel(channelId)?.addNote(note, null) + } + } + is LiveActivitiesChatMessageEvent -> { + draft.activityAddress()?.let { channelId -> + checkGetOrCreateChannel(channelId.toValue())?.addNote(note, null) } } is TextNoteEvent -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt deleted file mode 100644 index cbda34dba..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelView.kt +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewChannelView( - onClose: () -> Unit, - accountViewModel: AccountViewModel, - channel: PublicChatChannel? = null, -) { - val postViewModel: NewChannelViewModel = viewModel() - postViewModel.load(accountViewModel.account, channel) - - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - dismissOnClickOutside = false, - ), - ) { - Surface { - Column( - modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - postViewModel.clear() - onClose() - }, - ) - - PostButton( - onPost = { - postViewModel.create() - onClose() - }, - postViewModel.channelName.value.text - .isNotBlank(), - ) - } - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.channel_name)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.channelName.value, - onValueChange = { postViewModel.channelName.value = it }, - placeholder = { - Text( - text = stringRes(R.string.my_awesome_group), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.picture_url)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.channelPicture.value, - onValueChange = { postViewModel.channelPicture.value = it }, - placeholder = { - Text( - text = "http://mygroup.com/logo.jpg", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(15.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.description)) }, - modifier = Modifier.fillMaxWidth().height(100.dp), - value = postViewModel.channelDescription.value, - onValueChange = { postViewModel.channelDescription.value = it }, - placeholder = { - Text( - text = stringRes(R.string.about_us), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - maxLines = 10, - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index deaceaad6..dd15e1c50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.MessagesScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index ebe9ac545..75c952aea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -78,11 +78,11 @@ import com.vitorpamplona.amethyst.ui.layouts.LeftPictureLayout import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.EndedFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.OfflineFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.EndedFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.OfflineFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 64f7b6fa9..38d0257c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -123,7 +123,7 @@ 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.chats.public.RenderChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.RenderChannelHeader import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt index 3471a1d87..ef9db9c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/TimeAgo.kt @@ -23,12 +23,18 @@ package com.vitorpamplona.amethyst.ui.note.elements import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.note.timeAgo +import com.vitorpamplona.amethyst.ui.note.timeAgoShort +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable @@ -48,3 +54,21 @@ fun TimeAgo(time: Long) { maxLines = 1, ) } + +@Composable +fun NormalTimeAgo( + baseNote: Note, + modifier: Modifier, +) { + val nowStr = stringRes(id = R.string.now) + + val time by + remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } + + Text( + text = time, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index ba40ae89f..f6b911186 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index 488c3ee2c..1da46693b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -36,9 +38,11 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -60,12 +64,12 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay 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.note.elements.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.JoinCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.LeaveCommunityButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.NormalTimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.RowColSpacing @@ -82,6 +86,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.util.Locale @Composable @@ -359,3 +365,47 @@ fun WatchAddressableNoteFollows( onFollowChanges(state.addresses.contains(note.idHex)) } + +@Composable +fun JoinCommunityButton( + accountViewModel: AccountViewModel, + note: AddressableNote, + nav: INav, +) { + val scope = rememberCoroutineScope() + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.join), color = Color.White) + } +} + +@Composable +fun LeaveCommunityButton( + accountViewModel: AccountViewModel, + note: AddressableNote, + nav: INav, +) { + val scope = rememberCoroutineScope() + + Button( + modifier = Modifier.padding(horizontal = 3.dp), + onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(note) } }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.leave), color = Color.White) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 992e4f63c..e53c9e29c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -58,8 +58,8 @@ import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.DisplayAuthorBanner import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.LiveFlag -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ScheduledFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveFlag +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ScheduledFlag import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index 36d67889c..25e0c0d44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -33,7 +33,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 1f3ab1d03..6b131680e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser @@ -860,6 +861,14 @@ class AccountViewModel( account.decryptZapContentAuthor(note, onReady) } + fun follow(channel: Channel) { + viewModelScope.launch(Dispatchers.IO) { account.follow(channel) } + } + + fun unfollow(channel: Channel) { + viewModelScope.launch(Dispatchers.IO) { account.unfollow(channel) } + } + fun follow(user: User) { viewModelScope.launch(Dispatchers.IO) { account.follow(user) } } @@ -1054,6 +1063,8 @@ class AccountViewModel( } } + fun createRumor(template: EventTemplate) = account.signer.assembleRumor(template) + fun retrieveRelayDocument( dirtyUrl: String, onInfo: (Nip11RelayInformation) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt index 09d17a288..c6754eef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/ChannelFabColumn.kt @@ -48,10 +48,10 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.NewChannelView import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.buildNewPostRoute import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.Size55Modifier @@ -66,7 +66,7 @@ fun ChannelFabColumn( var wantsToCreateChannel by remember { mutableStateOf(false) } if (wantsToCreateChannel) { - NewChannelView({ wantsToCreateChannel = false }, accountViewModel = accountViewModel) + ChannelMetadataDialog({ wantsToCreateChannel = false }, accountViewModel = accountViewModel) } Column { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt index 053738bfd..a704aed7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/list/twopane/MessagesTwoPane.kt @@ -44,7 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.list.ChannelFabColumn import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.Chatroom -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.Channel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelView import com.vitorpamplona.amethyst.ui.theme.Size20dp @Composable @@ -113,7 +113,7 @@ fun MessagesTwoPane( } if (it.route == "Channel") { - Channel( + ChannelView( channelId = it.id, accountViewModel = accountViewModel, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt index 3a38db622..da0b88dd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomView.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -45,6 +44,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.ChatNewMessageViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.PrivateMessageEditFieldRow +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.launch @@ -176,7 +176,7 @@ fun ChatroomViewUI( ) } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = DoubleVertSpacer) val scope = rememberCoroutineScope() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt index 9ab47b27e..1b5478ea5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/messages/ChatroomMessageCompose.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.Font12SP 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 @@ -379,7 +380,7 @@ private fun RenderDraftEvent( nav: INav, ) { ObserveDraftEvent(note, accountViewModel) { - Column { + Column(verticalArrangement = RowColSpacing5dp) { RenderReplyRow( note = it, innerQuote = innerQuote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 3d00cec2a..4b65993ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -57,6 +57,7 @@ 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.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes import com.vitorpamplona.quartz.nip19Bech32.toNpub @@ -179,6 +180,12 @@ open class ChatNewMessageViewModel : ViewModel() { open fun reply(replyNote: Note) { replyTo.value = replyNote + saveDraft() + } + + fun clearReply() { + replyTo.value = null + saveDraft() } open fun quote(quote: Note) { @@ -224,8 +231,6 @@ open class ChatNewMessageViewModel : ViewModel() { } private fun loadFromDraft(draft: Note) { - Log.d("draft", draft.event!!.toJson()) - val draftEvent = draft.event ?: return val accountViewModel = accountViewModel ?: return @@ -264,9 +269,29 @@ open class ChatNewMessageViewModel : ViewModel() { TextFieldValue( draftEvent.groupMembers().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" }, ) + + val replyId = + when (draftEvent) { + is ChatMessageEvent -> draftEvent.replyTo().lastOrNull() + is ChatMessageEncryptedFileHeaderEvent -> draftEvent.replyTo().lastOrNull() + else -> null + } + + if (replyId != null) { + accountViewModel.checkGetOrCreateNote(replyId) { + replyTo.value = it + } + } } else if (draftEvent is PrivateDmEvent) { val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() } toUsers = TextFieldValue("@$recepientNpub") + + val replyId = draftEvent.replyTo() + if (replyId != null) { + accountViewModel.checkGetOrCreateNote(replyId) { + replyTo.value = it + } + } } message = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 6c4e3dba4..a27d6d035 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -42,12 +42,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.note.IncognitoIconOff import com.vitorpamplona.amethyst.ui.note.IncognitoIconOn @@ -58,11 +61,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldLeadingIconModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier import com.vitorpamplona.amethyst.ui.theme.Size30Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest @@ -70,6 +76,23 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +@Preview +@Composable +fun PrivateMessageEditFieldRow() { + val channelScreenModel: ChatNewMessageViewModel = viewModel() + val accountViewModel = mockAccountViewModel() + channelScreenModel.init(accountViewModel) + + ThemeComparisonRow { + PrivateMessageEditFieldRow( + channelScreenModel = channelScreenModel, + accountViewModel = accountViewModel, + onSendNewMessage = {}, + nav = EmptyNav, + ) + } +} + @Composable fun PrivateMessageEditFieldRow( channelScreenModel: ChatNewMessageViewModel, @@ -77,7 +100,11 @@ fun PrivateMessageEditFieldRow( onSendNewMessage: () -> Unit, nav: INav, ) { - channelScreenModel.replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { channelScreenModel.replyTo.value = null } } + channelScreenModel.replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + channelScreenModel.clearReply() + } + } LaunchedEffect(key1 = channelScreenModel.draftTag) { launch(Dispatchers.IO) { @@ -148,8 +175,7 @@ fun PrivateMessageEditFieldRow( }, trailingIcon = { ThinSendButton( - isActive = - channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, modifier = EditFieldTrailingIconModifier, ) { channelScreenModel.sendPost(onSendNewMessage) @@ -163,10 +189,7 @@ fun PrivateMessageEditFieldRow( SelectFromGallery( isUploading = channelScreenModel.isUploadingImage, tint = MaterialTheme.colorScheme.placeholderText, - modifier = - Modifier - .size(30.dp) - .padding(start = 2.dp), + modifier = EditFieldLeadingIconModifier, onImageChosen = channelScreenModel::pickedMedia, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 127aed8c7..8e72986a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -208,8 +208,8 @@ private fun ImageVideoPostChat( title = R.string.add_sensitive_content_label, description = R.string.add_sensitive_content_description, modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - checked = fileUploadState.sensitiveContent, - onCheckedChange = { fileUploadState.sensitiveContent = it }, + checked = fileUploadState.contentWarning, + onCheckedChange = fileUploadState::updateContentWarning, ) SettingsRow(R.string.file_server, R.string.file_server_description) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt index 9bc20bb7d..3919a2cc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadState.kt @@ -40,7 +40,11 @@ class ChatFileUploadState( var selectedServer by mutableStateOf(defaultServer) var caption by mutableStateOf("") - var sensitiveContent by mutableStateOf(false) + + var contentWarning by mutableStateOf(false) + private set + + var contentWarningReason by mutableStateOf(null) // Images and Videos var multiOrchestrator by mutableStateOf(null) @@ -72,4 +76,13 @@ class ChatFileUploadState( fun canPost(): Boolean = !isUploadingImage && multiOrchestrator != null fun hasPickedMedia() = multiOrchestrator != null + + fun updateContentWarning(value: Boolean) { + contentWarning = value + if (value) { + contentWarningReason = "" + } else { + contentWarningReason = null + } + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt index f70c67e5a..ea44e754a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploader.kt @@ -59,7 +59,7 @@ class ChatFileUploader( orchestrator.uploadEncrypted( scope, viewState.caption, - if (viewState.sensitiveContent) "" else null, + viewState.contentWarningReason, MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), cipher, viewState.selectedServer, @@ -88,9 +88,7 @@ class ChatFileUploader( alt(viewState.caption) } - if (viewState.sensitiveContent) { - contentWarning("") - } + viewState.contentWarningReason?.let { contentWarning(it) } } account.sendNIP17EncryptedFile(template) @@ -125,7 +123,7 @@ class ChatFileUploader( orchestrator.upload( scope, viewState.caption, - if (viewState.sensitiveContent) "" else null, + viewState.contentWarningReason, MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), viewState.selectedServer, account, @@ -136,7 +134,7 @@ class ChatFileUploader( results.successful.forEach { if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { val iMetaAttachments = IMetaAttachments() - iMetaAttachments.add(it.result, viewState.caption, if (viewState.sensitiveContent) "" else null) + iMetaAttachments.add(it.result, viewState.caption, viewState.contentWarningReason) account.sendPrivateMessage( message = it.result.url, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/public/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/public/ChannelScreen.kt deleted file mode 100644 index b0a4faf2e..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/public/ChannelScreen.kt +++ /dev/null @@ -1,1275 +0,0 @@ -/** - * Copyright (c) 2024 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -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.Icon -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.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.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -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.font.FontWeight -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.map -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.LiveActivitiesChannel -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.PublicChatChannel -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrChannelDataSource -import com.vitorpamplona.amethyst.service.uploads.CompressorQuality -import com.vitorpamplona.amethyst.service.uploads.MediaCompressor -import com.vitorpamplona.amethyst.ui.actions.NewChannelView -import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger -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.components.LoadNote -import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage -import com.vitorpamplona.amethyst.ui.components.SensitivityWarning -import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField -import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer -import com.vitorpamplona.amethyst.ui.components.ZoomableContentView -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton -import com.vitorpamplona.amethyst.ui.navigation.routeFor -import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture -import com.vitorpamplona.amethyst.ui.note.LikeReaction -import com.vitorpamplona.amethyst.ui.note.LoadChannel -import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture -import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay -import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList -import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.UserPicture -import com.vitorpamplona.amethyst.ui.note.UsernameDisplay -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.note.timeAgoShort -import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline -import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.ButtonPadding -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder -import com.vitorpamplona.amethyst.ui.theme.EditFieldLeadingIconModifier -import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier -import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier -import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier -import com.vitorpamplona.amethyst.ui.theme.RowColSpacing -import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.amethyst.ui.theme.Size34dp -import com.vitorpamplona.amethyst.ui.theme.Size35dp -import com.vitorpamplona.amethyst.ui.theme.SmallBorder -import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.ZeroPadding -import com.vitorpamplona.amethyst.ui.theme.innerPostModifier -import com.vitorpamplona.amethyst.ui.theme.liveStreamTag -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.tags.events.ETag -import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.references.references -import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList -import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists -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.nip18Reposts.quotes.quotes -import com.vitorpamplona.quartz.nip28PublicChat.base.notify -import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.emojis -import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent -import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag -import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag -import com.vitorpamplona.quartz.nip92IMeta.imetas -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.launch -import java.text.DateFormat -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -@Composable -fun ChannelScreen( - channelId: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (channelId == null) return - - DisappearingScaffold( - isInvertedLayout = true, - topBar = { - ChannelTopBar(channelId, accountViewModel, nav) - }, - accountViewModel = accountViewModel, - ) { - Column(Modifier.padding(it)) { - Channel(channelId, accountViewModel, nav) - } - } -} - -@Composable -fun Channel( - channelId: String?, - accountViewModel: AccountViewModel, - nav: INav, -) { - if (channelId == null) return - - LoadChannel(channelId, accountViewModel) { - PrepareChannelViewModels( - baseChannel = it, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun PrepareChannelViewModels( - baseChannel: Channel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val feedViewModel: NostrChannelFeedViewModel = - viewModel( - key = baseChannel.idHex + "ChannelFeedViewModel", - factory = - NostrChannelFeedViewModel.Factory( - baseChannel, - accountViewModel.account, - ), - ) - - val channelScreenModel: NewPostViewModel = viewModel() - channelScreenModel.accountViewModel = accountViewModel - channelScreenModel.account = accountViewModel.account - - ChannelScreen( - channel = baseChannel, - feedViewModel = feedViewModel, - newPostModel = channelScreenModel, - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -fun ChannelScreen( - channel: Channel, - feedViewModel: NostrChannelFeedViewModel, - newPostModel: NewPostViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(accountViewModel) { - NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - - onDispose { - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Channel Start") - NostrChannelDataSource.start() - feedViewModel.invalidateData(true) - } - if (event == Lifecycle.Event.ON_PAUSE) { - println("Channel Stop") - - NostrChannelDataSource.clear() - NostrChannelDataSource.stop() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - Column(Modifier.fillMaxHeight()) { - val replyTo = remember { mutableStateOf(null) } - - Column( - modifier = - remember { - Modifier - .fillMaxHeight() - .padding(vertical = 0.dp) - .weight(1f, true) - }, - ) { - if (channel is LiveActivitiesChannel) { - ShowVideoStreaming(channel, accountViewModel) - } - RefreshingChatroomFeedView( - viewModel = feedViewModel, - accountViewModel = accountViewModel, - nav = nav, - routeForLastRead = "Channel/${channel.idHex}", - avoidDraft = newPostModel.draftTag, - onWantsToReply = { replyTo.value = it }, - onWantsToEditDraft = { - newPostModel.load(accountViewModel, null, null, null, null, it) - }, - ) - } - - Spacer(modifier = DoubleVertSpacer) - - replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } } - - val scope = rememberCoroutineScope() - - LaunchedEffect(Unit) { - launch(Dispatchers.IO) { - newPostModel.draftTextChanges - .receiveAsFlow() - .debounce(1000) - .collectLatest { - innerSendPost(replyTo, channel, newPostModel, accountViewModel, newPostModel.draftTag) - } - } - } - - // LAST ROW - EditFieldRow(newPostModel, accountViewModel = accountViewModel) { - scope.launch(Dispatchers.IO) { - innerSendPost(replyTo, channel, newPostModel, accountViewModel, null) - newPostModel.message = TextFieldValue("") - replyTo.value = null - accountViewModel.deleteDraft(newPostModel.draftTag) - feedViewModel.sendToTop() - } - } - } -} - -@Composable -private fun ChannelTopBar( - id: String, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel -> - TopBarExtensibleWithBackButton( - title = { - ShortChannelHeader( - baseChannel = baseChannel, - accountViewModel = accountViewModel, - nav = nav, - showFlag = true, - ) - }, - extendableRow = { - LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) - }, - popBack = nav::popBack, - ) - } -} - -private suspend fun innerSendPost( - replyTo: MutableState, - channel: Channel, - newPostModel: NewPostViewModel, - accountViewModel: AccountViewModel, - draftTag: String?, -) { - val tagger = - NewMessageTagger( - message = newPostModel.message.text, - pTags = listOfNotNull(replyTo.value?.author), - eTags = listOfNotNull(replyTo.value), - channelHex = channel.idHex, - dao = accountViewModel, - ) - tagger.run() - - val urls = findURLs(tagger.message) - val usedAttachments = newPostModel.iMetaAttachments.filter { it.url in urls.toSet() } - val emojis = newPostModel.findEmoji(newPostModel.message.text, accountViewModel.account.myEmojis.value) - - val channelRelays = channel.relays() - - if (channel is PublicChatChannel) { - val replyingToEvent = replyTo.value?.toEventHint() - val channelEvent = channel.event - - val template = - if (replyingToEvent != null) { - ChannelMessageEvent.reply(tagger.message, replyingToEvent) { - notify(replyingToEvent.toPTag()) - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - emojis(emojis) - imetas(usedAttachments) - } - } else if (channelEvent != null) { - val hint = EventHintBundle(channelEvent, channelRelays.firstOrNull()) - ChannelMessageEvent.message(tagger.message, hint) { - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - emojis(emojis) - imetas(usedAttachments) - } - } else { - ChannelMessageEvent.message(tagger.message, ETag(channel.idHex, channelRelays.firstOrNull())) { - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - emojis(emojis) - imetas(usedAttachments) - } - } - - val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) - - accountViewModel.account.signAndSendWithList(draftTag, template, channelRelays, broadcast) - } else if (channel is LiveActivitiesChannel) { - val replyingToEvent = replyTo.value?.toEventHint() - val activity = channel.info - - val template = - if (replyingToEvent != null) { - LiveActivitiesChatMessageEvent.reply(tagger.message, replyingToEvent) { - notify(replyingToEvent.toPTag()) - - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - emojis(emojis) - imetas(usedAttachments) - } - } else if (activity != null) { - val hint = EventHintBundle(activity, channelRelays.firstOrNull() ?: replyingToEvent?.relay) - - LiveActivitiesChatMessageEvent.message(tagger.message, hint) { - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - emojis(emojis) - imetas(usedAttachments) - } - } else { - LiveActivitiesChatMessageEvent.message(tagger.message, channel.toATag()) { - hashtags(findHashtags(tagger.message)) - references(findURLs(tagger.message)) - quotes(findNostrUris(tagger.message)) - - emojis(emojis) - imetas(usedAttachments) - } - } - - val broadcast = tagger.directMentionsNotes + (tagger.eTags ?: emptyList()) - - accountViewModel.account.signAndSendWithList(draftTag, template, channelRelays, broadcast) - } -} - -@Composable -fun EditFieldRow( - channelScreenModel: NewPostViewModel, - accountViewModel: AccountViewModel, - onSendNewMessage: () -> Unit, -) { - Column( - modifier = EditFieldModifier, - ) { - val context = LocalContext.current - - ShowUserSuggestionList( - channelScreenModel.userSuggestions, - channelScreenModel::autocompleteWithUser, - accountViewModel, - ) - - ShowEmojiSuggestionList( - channelScreenModel.emojiSuggestions, - channelScreenModel::autocompleteWithEmoji, - channelScreenModel::autocompleteWithEmojiUrl, - accountViewModel, - ) - - ThinPaddingTextField( - value = channelScreenModel.message, - onValueChange = { channelScreenModel.updateMessage(it) }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - shape = EditFieldBorder, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - text = stringRes(R.string.reply_here), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - trailingIcon = { - ThinSendButton( - isActive = - channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, - modifier = EditFieldTrailingIconModifier, - ) { - onSendNewMessage() - } - }, - leadingIcon = { - SelectFromGallery( - isUploading = channelScreenModel.isUploadingImage, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = EditFieldLeadingIconModifier, - ) { - channelScreenModel.selectImage(it) - channelScreenModel.upload( - alt = null, - contentWarningReason = null, - // Use MEDIUM quality - mediaQuality = MediaCompressor.compressorQualityToInt(CompressorQuality.MEDIUM), - server = accountViewModel.account.settings.defaultFileServer, - onError = accountViewModel::toast, - context = context, - ) - } - }, - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - ) - } -} - -@Composable -fun RenderChannelHeader( - channelNote: Note, - showVideo: Boolean, - sendToChannel: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: INav, -) { - ChannelHeader( - channelNote = channelNote, - showVideo = showVideo, - sendToChannel = sendToChannel, - modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp), - accountViewModel = accountViewModel, - nav = nav, - ) -} - -@Composable -fun ChannelHeader( - channelNote: Note, - showVideo: Boolean, - sendToChannel: Boolean, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: INav, -) { - val channelHex by remember { derivedStateOf { channelNote.channelHex() } } - channelHex?.let { - ChannelHeader( - channelHex = it, - showVideo = showVideo, - sendToChannel = sendToChannel, - modifier = modifier, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun ChannelHeader( - channelHex: String, - showVideo: Boolean, - showFlag: Boolean = true, - sendToChannel: Boolean = false, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadChannel(channelHex, accountViewModel) { - ChannelHeader( - it, - showVideo, - showFlag, - sendToChannel, - modifier, - accountViewModel, - nav, - ) - } -} - -@Composable -fun ChannelHeader( - baseChannel: Channel, - showVideo: Boolean, - showFlag: Boolean = true, - sendToChannel: Boolean = false, - modifier: Modifier = StdPadding, - accountViewModel: AccountViewModel, - nav: INav, -) { - Column(Modifier.fillMaxWidth()) { - if (showVideo && baseChannel is LiveActivitiesChannel) { - ShowVideoStreaming(baseChannel, accountViewModel) - } - - val expanded = remember { mutableStateOf(false) } - - Column( - verticalArrangement = Arrangement.Center, - modifier = - modifier.clickable { - if (sendToChannel) { - nav.nav(routeFor(baseChannel)) - } else { - expanded.value = !expanded.value - } - }, - ) { - ShortChannelHeader( - baseChannel = baseChannel, - accountViewModel = accountViewModel, - nav = nav, - showFlag = showFlag, - ) - - if (expanded.value) { - LongChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) - } - } - } -} - -@Composable -fun ShowVideoStreaming( - baseChannel: LiveActivitiesChannel, - accountViewModel: AccountViewModel, -) { - baseChannel.info?.let { - SensitivityWarning( - event = it, - accountViewModel = accountViewModel, - ) { - val streamingInfo by - baseChannel.live - .map { - val activity = it.channel as? LiveActivitiesChannel - activity?.info - }.distinctUntilChanged() - .observeAsState(baseChannel.info) - - streamingInfo?.let { event -> - val url = remember(streamingInfo) { event.streaming() } - - url?.let { - CrossfadeCheckIfVideoIsOnline(url, accountViewModel) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = - remember { - Modifier - .fillMaxWidth() - .heightIn(min = 50.dp, max = 300.dp) - }, - ) { - val zoomableUrlVideo = - remember(streamingInfo) { - MediaUrlVideo( - url = url, - description = baseChannel.toBestDisplayName(), - artworkUri = event.image(), - authorName = baseChannel.creatorName(), - uri = baseChannel.toNAddr(), - ) - } - - ZoomableContentView( - content = zoomableUrlVideo, - roundedCorner = false, - contentScale = ContentScale.FillWidth, - accountViewModel = accountViewModel, - ) - } - } - } - } - } - } -} - -@Composable -fun ShortChannelHeader( - baseChannel: Channel, - accountViewModel: AccountViewModel, - nav: INav, - showFlag: Boolean, -) { - val channelState by baseChannel.live.observeAsState() - val channel = channelState?.channel ?: return - - Row(verticalAlignment = Alignment.CenterVertically) { - if (channel is LiveActivitiesChannel) { - channel.creator?.let { - UserPicture( - user = it, - size = Size34dp, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - channel.profilePicture()?.let { - RobohashFallbackAsyncImage( - robot = channel.idHex, - model = it, - contentDescription = stringRes(R.string.profile_image), - contentScale = ContentScale.Crop, - modifier = HeaderPictureModifier, - loadProfilePicture = accountViewModel.settings.showProfilePictures.value, - loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, - ) - } - } - - Column( - modifier = - Modifier - .padding(start = 10.dp) - .height(35.dp) - .weight(1f), - verticalArrangement = Arrangement.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = remember(channelState) { channel.toBestDisplayName() }, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Row( - modifier = - Modifier - .height(Size35dp) - .padding(start = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (channel is PublicChatChannel) { - ShortChannelActionOptions(channel, accountViewModel, nav) - } - if (channel is LiveActivitiesChannel) { - LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) - } - } - } -} - -@Composable -fun LongChannelHeader( - baseChannel: Channel, - lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), - accountViewModel: AccountViewModel, - nav: INav, -) { - val channelState by baseChannel.live.observeAsState() - val channel = channelState?.channel ?: return - - Row( - lineModifier, - ) { - val summary = remember(channelState) { channel.summary()?.ifBlank { null } } - - Column( - Modifier.weight(1f), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val defaultBackground = MaterialTheme.colorScheme.background - val background = remember { mutableStateOf(defaultBackground) } - - val tags = - remember(channelState) { - if (baseChannel is LiveActivitiesChannel) { - baseChannel.info?.tags?.toImmutableListOfLists() ?: EmptyTagList - } else { - EmptyTagList - } - } - - TranslatableRichTextViewer( - content = summary ?: stringRes(id = R.string.groups_no_descriptor), - canPreview = false, - quotesLeft = 1, - tags = tags, - backgroundColor = background, - id = baseChannel.idHex, - accountViewModel = accountViewModel, - nav = nav, - ) - } - - if (baseChannel is LiveActivitiesChannel && summary != null) { - baseChannel.info?.let { - if (it.hasHashtags()) { - DisplayUncitedHashtags(it, summary, nav) - } - } - } - } - - Column { - if (channel is PublicChatChannel) { - Row { - Spacer(DoubleHorzSpacer) - LongChannelActionOptions(channel, accountViewModel, nav) - } - } - } - } - - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> - loadingNote?.let { note -> - Row( - lineModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(id = R.string.owner), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NoteAuthorPicture(note, nav, accountViewModel, Size25dp) - Spacer(DoubleHorzSpacer) - NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) - } - - Row( - lineModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringRes(id = R.string.created_at), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(75.dp), - ) - Spacer(DoubleHorzSpacer) - NormalTimeAgo(note, remember { Modifier.weight(1f) }) - MoreOptionsButton(note, null, accountViewModel, nav) - } - } - } - - if (channel is LiveActivitiesChannel) { - var participantUsers by remember(baseChannel) { - mutableStateOf>>( - persistentListOf(), - ) - } - - LaunchedEffect(key1 = channelState) { - launch(Dispatchers.IO) { - val newParticipantUsers = - channel.info - ?.participants() - ?.mapNotNull { part -> - LocalCache.checkGetOrCreateUser(part.pubKey)?.let { Pair(part, it) } - }?.toImmutableList() - - if ( - newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) - ) { - participantUsers = newParticipantUsers - } - } - } - - participantUsers.forEach { - Row( - lineModifier.clickable { nav.nav("User/${it.second.pubkeyHex}") }, - verticalAlignment = Alignment.CenterVertically, - ) { - it.first.role?.let { it1 -> - Text( - text = it1.capitalize(Locale.ROOT), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.width(55.dp), - ) - } - Spacer(DoubleHorzSpacer) - ClickableUserPicture(it.second, Size25dp, accountViewModel) - Spacer(DoubleHorzSpacer) - UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel) - } - } - } -} - -@Composable -fun NormalTimeAgo( - baseNote: Note, - modifier: Modifier, -) { - val nowStr = stringRes(id = R.string.now) - - val time by - remember(baseNote) { derivedStateOf { timeAgoShort(baseNote.createdAt() ?: 0, nowStr) } } - - Text( - text = time, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = modifier, - ) -} - -@Composable -private fun ShortChannelActionOptions( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { - LoadNote(baseNoteHex = channel.idHex, accountViewModel) { - it?.let { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) { - LikeReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - ZapReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - Spacer(modifier = StdHorzSpacer) - } - } - } - - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (!isFollowing) { - JoinChatButton(accountViewModel, channel, nav) - } - } -} - -@Composable -private fun WatchChannelFollows( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - content: @Composable (Boolean) -> Unit, -) { - val isFollowing by - accountViewModel - .userProfile() - .live() - .follows - .map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false } - .distinctUntilChanged() - .observeAsState( - accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false, - ) - - content(isFollowing) -} - -@Composable -private fun LongChannelActionOptions( - channel: PublicChatChannel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val isMe by - remember(accountViewModel) { - derivedStateOf { channel.creator == accountViewModel.account.userProfile() } - } - - if (isMe) { - EditButton(accountViewModel, channel) - } - - WatchChannelFollows(channel, accountViewModel) { isFollowing -> - if (isFollowing) { - LeaveChatButton(accountViewModel, channel, nav) - } - } -} - -@Composable -private fun LiveChannelActionOptions( - channel: LiveActivitiesChannel, - showFlag: Boolean = true, - accountViewModel: AccountViewModel, - nav: INav, -) { - val isLive by remember(channel) { derivedStateOf { channel.info?.status() == StatusTag.STATUS.LIVE.code } } - - val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } - - note?.let { - if (showFlag && isLive) { - LiveFlag() - Spacer(modifier = StdHorzSpacer) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = RowColSpacing, - ) { - LikeReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav, - ) - } - Spacer(modifier = StdHorzSpacer) - ZapReaction( - baseNote = it, - grayTint = MaterialTheme.colorScheme.onSurface, - accountViewModel = accountViewModel, - nav = nav, - ) - } -} - -@Composable -fun LiveFlag() { - Text( - text = stringRes(id = R.string.live_stream_live_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - fontSize = 16.sp, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Red) - .padding(horizontal = 5.dp) - }, - ) -} - -@Composable -fun EndedFlag() { - Text( - text = stringRes(id = R.string.live_stream_ended_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, - ) -} - -@Composable -fun OfflineFlag() { - Text( - text = stringRes(id = R.string.live_stream_offline_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = - remember { - Modifier - .clip(SmallBorder) - .background(Color.Black) - .padding(horizontal = 5.dp) - }, - ) -} - -@Composable -fun ScheduledFlag(starts: Long?) { - val startsIn = - starts?.let { - SimpleDateFormat - .getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT, - ).format(Date(starts * 1000)) - } - - Text( - text = startsIn ?: stringRes(id = R.string.live_stream_planned_tag), - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = liveStreamTag, - ) -} - -@Composable -private fun NoteCopyButton(note: Channel) { - var popupExpanded by remember { mutableStateOf(false) } - - Button( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { popupExpanded = true }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.placeholderText, - ), - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.Share, - contentDescription = stringRes(R.string.copies_the_note_id_to_the_clipboard_for_sharing), - ) - - DropdownMenu( - expanded = popupExpanded, - onDismissRequest = { popupExpanded = false }, - ) { - val clipboardManager = LocalClipboardManager.current - - DropdownMenuItem( - text = { Text(stringRes(R.string.copy_channel_id_note_to_the_clipboard)) }, - onClick = { - clipboardManager.setText(AnnotatedString("nostr:" + note.idNote())) - popupExpanded = false - }, - ) - } - } -} - -@Composable -private fun EditButton( - accountViewModel: AccountViewModel, - channel: PublicChatChannel, -) { - var wantsToPost by remember { mutableStateOf(false) } - - if (wantsToPost) { - NewChannelView({ wantsToPost = false }, accountViewModel, channel) - } - - Button( - modifier = - Modifier - .padding(horizontal = 3.dp) - .width(50.dp), - onClick = { wantsToPost = true }, - contentPadding = ZeroPadding, - ) { - Icon( - tint = Color.White, - imageVector = Icons.Default.EditNote, - contentDescription = stringRes(R.string.edits_the_channel_metadata), - ) - } -} - -@Composable -fun JoinChatButton( - accountViewModel: AccountViewModel, - channel: Channel, - nav: INav, -) { - val scope = rememberCoroutineScope() - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(channel) } }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.join), color = Color.White) - } -} - -@Composable -fun LeaveChatButton( - accountViewModel: AccountViewModel, - channel: Channel, - nav: INav, -) { - val scope = rememberCoroutineScope() - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(channel) } }, - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.leave), color = Color.White) - } -} - -@Composable -fun JoinCommunityButton( - accountViewModel: AccountViewModel, - note: AddressableNote, - nav: INav, -) { - val scope = rememberCoroutineScope() - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.follow(note) } }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.join), color = Color.White) - } -} - -@Composable -fun LeaveCommunityButton( - accountViewModel: AccountViewModel, - note: AddressableNote, - nav: INav, -) { - val scope = rememberCoroutineScope() - - Button( - modifier = Modifier.padding(horizontal = 3.dp), - onClick = { scope.launch(Dispatchers.IO) { accountViewModel.account.unfollow(note) } }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - contentPadding = ButtonPadding, - ) { - Text(text = stringRes(R.string.leave), color = Color.White) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt new file mode 100644 index 000000000..8143f1b4a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelHeader.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivitiesChannelHeader +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier + +@Composable +fun RenderChannelHeader( + channelNote: Note, + showVideo: Boolean, + sendToChannel: Boolean, + accountViewModel: AccountViewModel, + nav: INav, +) { + channelNote.channelHex()?.let { + ChannelHeader( + channelHex = it, + showVideo = showVideo, + sendToChannel = sendToChannel, + modifier = MaterialTheme.colorScheme.innerPostModifier.padding(Size10dp), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun ChannelHeader( + channelHex: String, + showVideo: Boolean, + showFlag: Boolean = true, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadChannel(channelHex, accountViewModel) { + when (it) { + is LiveActivitiesChannel -> + LiveActivitiesChannelHeader( + it, + showVideo, + showFlag, + sendToChannel, + modifier, + accountViewModel, + nav, + ) + is PublicChatChannel -> + PublicChatChannelHeader( + it, + sendToChannel, + modifier, + accountViewModel, + nav, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt new file mode 100644 index 000000000..99f1c3b11 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelScreen.kt @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.PublicChatTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header.LiveActivityTopBar + +@Composable +fun ChannelScreen( + channelId: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + DisappearingScaffold( + isInvertedLayout = true, + topBar = { + LoadChannel(channelId, accountViewModel) { + when (it) { + is PublicChatChannel -> PublicChatTopBar(it, accountViewModel, nav) + is LiveActivitiesChannel -> LiveActivityTopBar(it, accountViewModel, nav) + } + } + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + ChannelView(channelId, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt new file mode 100644 index 000000000..879af0e40 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ChannelView.kt @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.model.Channel +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.service.NostrChannelDataSource +import com.vitorpamplona.amethyst.service.NostrChannelDataSource.channel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LoadChannel +import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.feed.RefreshingChatroomFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShowVideoStreaming +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.ChannelNewMessageViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send.EditFieldRow +import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer +import kotlinx.coroutines.launch + +@Composable +fun ChannelView( + channelId: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (channelId == null) return + + LoadChannel(channelId, accountViewModel) { + PrepareChannelViewModels( + baseChannel = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun PrepareChannelViewModels( + baseChannel: Channel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: NostrChannelFeedViewModel = + viewModel( + key = baseChannel.idHex + "ChannelFeedViewModel", + factory = + NostrChannelFeedViewModel.Factory( + baseChannel, + accountViewModel.account, + ), + ) + + val channelScreenModel: ChannelNewMessageViewModel = viewModel() + channelScreenModel.init(accountViewModel) + channelScreenModel.load(baseChannel) + + ChannelView( + channel = baseChannel, + feedViewModel = feedViewModel, + newPostModel = channelScreenModel, + accountViewModel = accountViewModel, + nav = nav, + ) +} + +@Composable +fun ChannelView( + channel: Channel, + feedViewModel: NostrChannelFeedViewModel, + newPostModel: ChannelNewMessageViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) + + val lifeCycleOwner = LocalLifecycleOwner.current + + DisposableEffect(accountViewModel) { + NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel) + NostrChannelDataSource.start() + feedViewModel.invalidateData(true) + + onDispose { + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() + } + } + + DisposableEffect(lifeCycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + println("Channel Start") + NostrChannelDataSource.start() + feedViewModel.invalidateData(true) + } + if (event == Lifecycle.Event.ON_PAUSE) { + println("Channel Stop") + + NostrChannelDataSource.clear() + NostrChannelDataSource.stop() + } + } + + lifeCycleOwner.lifecycle.addObserver(observer) + onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } + } + + Column(Modifier.fillMaxHeight()) { + Column( + modifier = + remember { + Modifier + .fillMaxHeight() + .padding(vertical = 0.dp) + .weight(1f, true) + }, + ) { + if (channel is LiveActivitiesChannel) { + ShowVideoStreaming(channel, accountViewModel) + } + RefreshingChatroomFeedView( + viewModel = feedViewModel, + accountViewModel = accountViewModel, + nav = nav, + routeForLastRead = "Channel/${channel.idHex}", + avoidDraft = newPostModel.draftTag, + onWantsToReply = newPostModel::reply, + onWantsToEditDraft = newPostModel::editFromDraft, + ) + } + + Spacer(modifier = DoubleVertSpacer) + + val scope = rememberCoroutineScope() + + // LAST ROW + EditFieldRow( + newPostModel, + accountViewModel, + onSendNewMessage = { + scope.launch { + feedViewModel.sendToTop() + } + }, + nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongChannelActionOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongChannelActionOptions.kt new file mode 100644 index 000000000..84a146658 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongChannelActionOptions.kt @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.EditButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.JoinChatButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions.LeaveChatButton +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent + +@Composable +fun ShortChannelActionOptions( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { + it?.let { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = RowColSpacing) { + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdHorzSpacer) + } + } + } + + WatchChannelFollows(channel, accountViewModel) { isFollowing -> + if (!isFollowing) { + JoinChatButton(channel, accountViewModel, nav) + } + } +} + +@Composable +fun LongChannelActionOptions( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isMe by + remember(accountViewModel) { + derivedStateOf { channel.creator == accountViewModel.account.userProfile() } + } + + if (isMe) { + EditButton(channel, accountViewModel) + } + + WatchChannelFollows(channel, accountViewModel) { isFollowing -> + if (isFollowing) { + LeaveChatButton(channel, accountViewModel, nav) + } + } +} + +@Composable +private fun WatchChannelFollows( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + content: @Composable (Boolean) -> Unit, +) { + val isFollowing by + accountViewModel + .userProfile() + .live() + .follows + .map { it.user.latestContactList?.isTaggedEvent(channel.idHex) ?: false } + .distinctUntilChanged() + .observeAsState( + accountViewModel.userProfile().latestContactList?.isTaggedEvent(channel.idHex) ?: false, + ) + + content(isFollowing) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt new file mode 100644 index 000000000..1bd744c6d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -0,0 +1,123 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList + +@Composable +fun LongPublicChatChannelHeader( + baseChannel: PublicChatChannel, + lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel as? PublicChatChannel ?: return + + Row(lineModifier) { + val summary = remember(channelState) { channel.summary()?.ifBlank { null } } + + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + TranslatableRichTextViewer( + content = summary ?: stringRes(id = R.string.groups_no_descriptor), + canPreview = false, + quotesLeft = 1, + tags = EmptyTagList, + backgroundColor = background, + id = baseChannel.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + + Spacer(DoubleHorzSpacer) + LongChannelActionOptions(channel, accountViewModel, nav) + } + + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> + loadingNote?.let { note -> + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(note, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(note, remember { Modifier.weight(1f) }) + MoreOptionsButton(note, null, accountViewModel, nav) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt new file mode 100644 index 000000000..534593289 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatChannelHeader.kt @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.PublicChatChannel +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.theme.StdPadding + +@Composable +fun PublicChatChannelHeader( + baseChannel: PublicChatChannel, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.fillMaxWidth()) { + val expanded = remember { mutableStateOf(false) } + + Column( + verticalArrangement = Arrangement.Center, + modifier = + modifier.clickable { + if (sendToChannel) { + nav.nav(routeFor(baseChannel)) + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortPublicChatChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + + if (expanded.value) { + LongPublicChatChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt new file mode 100644 index 000000000..a4a42d252 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/PublicChatTopBar.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun PublicChatTopBar( + baseChannel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + TopBarExtensibleWithBackButton( + title = { + ShortPublicChatChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + extendableRow = { + LongPublicChatChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + }, + popBack = nav::popBack, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt new file mode 100644 index 000000000..f7da4d439 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -0,0 +1,96 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.PublicChatChannel +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.stringRes +import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier +import com.vitorpamplona.amethyst.ui.theme.Size35dp + +@Composable +fun ShortPublicChatChannelHeader( + baseChannel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel as? PublicChatChannel ?: return + + Row(verticalAlignment = Alignment.CenterVertically) { + channel.profilePicture()?.let { + RobohashFallbackAsyncImage( + robot = channel.idHex, + model = it, + contentDescription = stringRes(R.string.profile_image), + contentScale = ContentScale.Crop, + modifier = HeaderPictureModifier, + loadProfilePicture = accountViewModel.settings.showProfilePictures.value, + loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, + ) + } + + Column( + Modifier + .padding(start = 10.dp) + .height(35.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(channelState) { channel.toBestDisplayName() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ShortChannelActionOptions(channel, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt new file mode 100644 index 000000000..2897570b8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/EditChatButton.kt @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataDialog +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ZeroPadding + +@Composable +fun EditButton( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, +) { + var wantsToPost by remember { mutableStateOf(false) } + + if (wantsToPost) { + ChannelMetadataDialog({ wantsToPost = false }, accountViewModel, channel) + } + + Button( + modifier = + Modifier + .padding(horizontal = 3.dp) + .width(50.dp), + onClick = { wantsToPost = true }, + contentPadding = ZeroPadding, + ) { + Icon( + tint = Color.White, + imageVector = Icons.Default.EditNote, + contentDescription = stringRes(R.string.edits_the_channel_metadata), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt new file mode 100644 index 000000000..35dbf3965 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/JoinChatButton.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier + +@Composable +fun JoinChatButton( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Button( + modifier = HalfHalfHorzModifier, + onClick = { accountViewModel.follow(channel) }, + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.join), color = Color.White) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt new file mode 100644 index 000000000..c91f1c045 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/actions/LeaveChatButton.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.header.actions + +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonPadding +import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier + +@Composable +fun LeaveChatButton( + channel: PublicChatChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Button( + modifier = HalfHalfHorzModifier, + onClick = { accountViewModel.unfollow(channel) }, + contentPadding = ButtonPadding, + ) { + Text(text = stringRes(R.string.leave), color = Color.White) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataDialog.kt new file mode 100644 index 000000000..3cc03879f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataDialog.kt @@ -0,0 +1,169 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.CloseButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.PostButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText + +@Composable +fun ChannelMetadataDialog( + onClose: () -> Unit, + accountViewModel: AccountViewModel, + channel: PublicChatChannel? = null, +) { + val postViewModel: ChannelMetadataViewModel = viewModel() + postViewModel.load(accountViewModel.account, channel) + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + dismissOnClickOutside = false, + ), + ) { + DialogContent(postViewModel, onClose) + } +} + +@Composable +private fun DialogContent( + postViewModel: ChannelMetadataViewModel, + onClose: () -> Unit, +) { + Surface { + Column( + modifier = + Modifier + .padding(10.dp) + .verticalScroll(rememberScrollState()), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + CloseButton( + onPress = { + postViewModel.clear() + onClose() + }, + ) + + PostButton( + onPost = { + postViewModel.create() + onClose() + }, + postViewModel.channelName.value.text + .isNotBlank(), + ) + } + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.channel_name)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelName.value, + onValueChange = { postViewModel.channelName.value = it }, + placeholder = { + Text( + text = stringRes(R.string.my_awesome_group), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.picture_url)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.channelPicture.value, + onValueChange = { postViewModel.channelPicture.value = it }, + placeholder = { + Text( + text = "http://mygroup.com/logo.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + + Spacer(modifier = Modifier.height(15.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.description)) }, + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), + value = postViewModel.channelDescription.value, + onValueChange = { postViewModel.channelDescription.value = it }, + placeholder = { + Text( + text = stringRes(R.string.about_us), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + maxLines = 10, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt index de37917fd..b16d9317c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewChannelViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/metadata/ChannelMetadataViewModel.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.ui.actions +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.input.TextFieldValue @@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class NewChannelViewModel : ViewModel() { +class ChannelMetadataViewModel : ViewModel() { private var account: Account? = null private var originalChannel: PublicChatChannel? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt new file mode 100644 index 000000000..bd87cee07 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveActivitiesChannelHeader.kt @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +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.theme.StdPadding + +@Composable +fun LiveActivitiesChannelHeader( + baseChannel: LiveActivitiesChannel, + showVideo: Boolean, + showFlag: Boolean = true, + sendToChannel: Boolean = false, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(Modifier.fillMaxWidth()) { + if (showVideo) { + ShowVideoStreaming(baseChannel, accountViewModel) + } + + val expanded = remember { mutableStateOf(false) } + + Column( + verticalArrangement = Arrangement.Center, + modifier = + modifier.clickable { + if (sendToChannel) { + nav.nav(routeFor(baseChannel)) + } else { + expanded.value = !expanded.value + } + }, + ) { + ShortLiveActivityChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + showFlag = showFlag, + ) + + if (expanded.value) { + LongLiveActivityChannelHeader(baseChannel, accountViewModel = accountViewModel, nav = nav) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt new file mode 100644 index 000000000..6f4ef4ddf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LiveChannelActionOptions.kt @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.LikeReaction +import com.vitorpamplona.amethyst.ui.note.ZapReaction +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.RowColSpacing +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag + +@Composable +fun LiveChannelActionOptions( + channel: LiveActivitiesChannel, + showFlag: Boolean = true, + accountViewModel: AccountViewModel, + nav: INav, +) { + val isLive by remember(channel) { derivedStateOf { channel.info?.status() == StatusTag.STATUS.LIVE.code } } + + val note = remember(channel.idHex) { LocalCache.getNoteIfExists(channel.idHex) } + + note?.let { + if (showFlag && isLive) { + LiveFlag() + Spacer(modifier = StdHorzSpacer) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = RowColSpacing, + ) { + LikeReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav, + ) + } + Spacer(modifier = StdHorzSpacer) + ZapReaction( + baseNote = it, + grayTint = MaterialTheme.colorScheme.onSurface, + accountViewModel = accountViewModel, + nav = nav, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt new file mode 100644 index 000000000..43978417a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/LongLiveActivityChannelHeader.kt @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture +import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.elements.MoreOptionsButton +import com.vitorpamplona.amethyst.ui.note.elements.NormalTimeAgo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip02FollowList.EmptyTagList +import com.vitorpamplona.quartz.nip02FollowList.toImmutableListOfLists +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Locale + +@Composable +fun LongLiveActivityChannelHeader( + baseChannel: LiveActivitiesChannel, + lineModifier: Modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), + accountViewModel: AccountViewModel, + nav: INav, +) { + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel as? LiveActivitiesChannel ?: return + + Row( + lineModifier, + ) { + val summary = remember(channelState) { channel.summary()?.ifBlank { null } } + + Column( + Modifier.weight(1f), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val defaultBackground = MaterialTheme.colorScheme.background + val background = remember { mutableStateOf(defaultBackground) } + + val tags = remember(channelState) { baseChannel.info?.tags?.toImmutableListOfLists() ?: EmptyTagList } + + TranslatableRichTextViewer( + content = summary ?: stringRes(id = R.string.groups_no_descriptor), + canPreview = false, + quotesLeft = 1, + tags = tags, + backgroundColor = background, + id = baseChannel.idHex, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + if (summary != null) { + baseChannel.info?.let { + if (it.hasHashtags()) { + DisplayUncitedHashtags(it, summary, nav) + } + } + } + } + } + + LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote -> + loadingNote?.let { note -> + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.owner), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NoteAuthorPicture(note, nav, accountViewModel, Size25dp) + Spacer(DoubleHorzSpacer) + NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) + } + + Row( + lineModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.created_at), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(75.dp), + ) + Spacer(DoubleHorzSpacer) + NormalTimeAgo(note, remember { Modifier.weight(1f) }) + MoreOptionsButton(note, null, accountViewModel, nav) + } + } + } + + var participantUsers by remember(baseChannel) { + mutableStateOf>>( + persistentListOf(), + ) + } + + LaunchedEffect(key1 = channelState) { + launch(Dispatchers.IO) { + val newParticipantUsers = + channel.info + ?.participants() + ?.mapNotNull { part -> + LocalCache.checkGetOrCreateUser(part.pubKey)?.let { Pair(part, it) } + }?.toImmutableList() + + if ( + newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers) + ) { + participantUsers = newParticipantUsers + } + } + } + + participantUsers.forEach { + Row( + lineModifier.clickable { nav.nav("User/${it.second.pubkeyHex}") }, + verticalAlignment = Alignment.CenterVertically, + ) { + it.first.role?.let { it1 -> + Text( + text = it1.capitalize(Locale.ROOT), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.width(55.dp), + ) + } + Spacer(DoubleHorzSpacer) + ClickableUserPicture(it.second, Size25dp, accountViewModel) + Spacer(DoubleHorzSpacer) + UsernameDisplay(it.second, Modifier.weight(1f), accountViewModel = accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt new file mode 100644 index 000000000..c1767758d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShortLiveActivityChannelHeader.kt @@ -0,0 +1,91 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size34dp +import com.vitorpamplona.amethyst.ui.theme.Size35dp + +@Composable +fun ShortLiveActivityChannelHeader( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, + showFlag: Boolean, +) { + val channelState by baseChannel.live.observeAsState() + val channel = channelState?.channel as? LiveActivitiesChannel ?: return + + Row(verticalAlignment = Alignment.CenterVertically) { + channel.creator?.let { + UserPicture( + user = it, + size = Size34dp, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .height(35.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = remember(channelState) { channel.toBestDisplayName() }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + modifier = + Modifier + .height(Size35dp) + .padding(start = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LiveChannelActionOptions(channel, showFlag, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt new file mode 100644 index 000000000..e8c3d106d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/ShowVideoStreaming.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.layout.ContentScale +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.ZoomableContentView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline +import com.vitorpamplona.amethyst.ui.theme.StreamingHeaderModifier + +@Composable +fun ShowVideoStreaming( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, +) { + baseChannel.info?.let { + SensitivityWarning( + event = it, + accountViewModel = accountViewModel, + ) { + val streamingInfoEvent by + baseChannel.live + .map { + (it.channel as? LiveActivitiesChannel)?.info + }.distinctUntilChanged() + .observeAsState(baseChannel.info) + + streamingInfoEvent?.let { event -> + event.streaming()?.let { url -> + CrossfadeCheckIfVideoIsOnline(url, accountViewModel) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = StreamingHeaderModifier, + ) { + val zoomableUrlVideo = + remember(streamingInfoEvent) { + MediaUrlVideo( + url = url, + description = baseChannel.toBestDisplayName(), + artworkUri = event.image(), + authorName = baseChannel.creatorName(), + uri = baseChannel.toNAddr(), + ) + } + + ZoomableContentView( + content = zoomableUrlVideo, + roundedCorner = false, + contentScale = ContentScale.FillWidth, + accountViewModel = accountViewModel, + ) + } + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt new file mode 100644 index 000000000..e2957d7fd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/StreamingStatusFlags.kt @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.liveStreamTag +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date + +@Composable +fun LiveFlag() { + Text( + text = stringRes(id = R.string.live_stream_live_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 16.sp, + modifier = + remember { + Modifier + .clip(SmallBorder) + .background(Color.Red) + .padding(horizontal = 5.dp) + }, + ) +} + +@Composable +fun EndedFlag() { + Text( + text = stringRes(id = R.string.live_stream_ended_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, + ) +} + +@Composable +fun OfflineFlag() { + Text( + text = stringRes(id = R.string.live_stream_offline_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, + ) +} + +@Composable +fun ScheduledFlag(starts: Long?) { + val startsIn = + starts?.let { + SimpleDateFormat + .getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + ).format(Date(starts * 1000)) + } + + Text( + text = startsIn ?: stringRes(id = R.string.live_stream_planned_tag), + color = Color.White, + fontWeight = FontWeight.Bold, + modifier = liveStreamTag, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt new file mode 100644 index 000000000..8a713e17e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip53LiveActivities/header/LiveActivityTopBar.kt @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.header + +import androidx.compose.runtime.Composable +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.navigation.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LongLiveActivityChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.ShortLiveActivityChannelHeader + +@Composable +fun LiveActivityTopBar( + baseChannel: LiveActivitiesChannel, + accountViewModel: AccountViewModel, + nav: INav, +) { + TopBarExtensibleWithBackButton( + title = { + ShortLiveActivityChannelHeader( + baseChannel = baseChannel, + accountViewModel = accountViewModel, + nav = nav, + showFlag = true, + ) + }, + extendableRow = { + LongLiveActivityChannelHeader(baseChannel = baseChannel, accountViewModel = accountViewModel, nav = nav) + }, + popBack = nav::popBack, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt new file mode 100644 index 000000000..19be89cec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -0,0 +1,653 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +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.Channel +import com.vitorpamplona.amethyst.model.LiveActivitiesChannel +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.PublicChatChannel +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.LocationState +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.uploads.MediaCompressor +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.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia +import com.vitorpamplona.amethyst.ui.components.Split +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.UserSuggestions +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.upload.ChatFileUploadState +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.getGeoHash +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.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip28PublicChat.base.notify +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +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.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.notify +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.nip92IMeta.imetas +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.Dispatchers +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 ChannelNewMessageViewModel : ViewModel() { + var draftTag: String by mutableStateOf(UUID.randomUUID().toString()) + + var accountViewModel: AccountViewModel? = null + var account: Account? = null + var channel: Channel? = null + + val replyTo = mutableStateOf(null) + + var uploadState by mutableStateOf(null) + val iMetaAttachments = IMetaAttachments() + var nip95attachments by mutableStateOf>>(emptyList()) + + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + + val userSuggestions = UserSuggestions() + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + val emojiSearch: MutableStateFlow = MutableStateFlow("") + val emojiSuggestions: StateFlow> by lazy { + account!! + .myEmojis + .combine(emojiSearch) { list, search -> + if (search.length == 1) { + list + } else if (search.isNotEmpty()) { + val code = search.removePrefix(":") + list.filter { it.code.startsWith(code) } + } else { + emptyList() + } + }.flowOn(Dispatchers.Default) + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList(), + ) + } + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + // Forward Zap to + var wantsForwardZapTo by mutableStateOf(false) + var forwardZapTo by mutableStateOf>(Split()) + var forwardZapToEditting by mutableStateOf(TextFieldValue("")) + + // NSFW, Sensitive + var wantsToMarkAsSensitive by mutableStateOf(false) + + // GeoHash + var wantsToAddGeoHash by mutableStateOf(false) + var location: StateFlow? = null + var wantsExclusiveGeoPost by mutableStateOf(false) + + // ZapRaiser + var canAddZapRaiser by mutableStateOf(false) + var wantsZapraiser by mutableStateOf(false) + var zapRaiserAmount by mutableStateOf(null) + + val draftTextChanges = kotlinx.coroutines.channels.Channel(kotlinx.coroutines.channels.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 = hasLnAddress() + this.canAddZapRaiser = hasLnAddress() + + this.uploadState = + ChatFileUploadState( + account?.settings?.defaultFileServer ?: DEFAULT_MEDIA_SERVERS[0], + ) + } + + open fun load(channel: Channel) { + this.channel = channel + } + + open fun reply(replyNote: Note) { + replyTo.value = replyNote + saveDraft() + } + + fun clearReply() { + replyTo.value = null + saveDraft() + } + + 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) { + val draftEvent = draft.event ?: 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 geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + + val zapraiser = draftEvent.zapraiserAmount() + wantsZapraiser = zapraiser != null + zapRaiserAmount = null + if (zapraiser != null) { + zapRaiserAmount = zapraiser + } + + if (forwardZapTo.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + if (draftEvent as? ChannelMessageEvent != null) { + val replyId = draftEvent.reply()?.eventId + if (replyId != null) { + accountViewModel?.checkGetOrCreateNote(replyId) { + replyTo.value = it + } + } + } else if (draftEvent as? LiveActivitiesChatMessageEvent != null) { + val replyId = draftEvent.reply()?.eventId + if (replyId != null) { + accountViewModel?.checkGetOrCreateNote(replyId) { + replyTo.value = it + } + } + } + + message = TextFieldValue(draftEvent.content) + + urlPreview = findUrlInMessage() + } + + fun sendPost(onDone: () -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + sendPostSync() + onDone() + } + } + + suspend fun sendPostSync() { + val template = createTemplate() ?: return + val channelRelays = channel?.relays() ?: emptyList() + + accountViewModel?.account?.signAndSendPrivately(template, channelRelays) + + accountViewModel?.deleteDraft(draftTag) + + cancel() + } + + fun sendDraft() { + viewModelScope.launch(Dispatchers.IO) { + sendDraftSync() + } + } + + suspend fun sendDraftSync() { + val accountViewModel = accountViewModel ?: return + + if (message.text.isBlank()) { + account?.deleteDraft(draftTag) + } else { + val template = createTemplate() ?: return + accountViewModel.account.createAndSendDraft(draftTag, template) + } + } + + fun pickedMedia(list: ImmutableList) { + uploadState?.load(list) + } + + fun upload( + onError: (title: String, message: String) -> Unit, + context: Context, + ) { + viewModelScope.launch(Dispatchers.Default) { + val myAccount = account ?: return@launch + val uploadState = uploadState ?: return@launch + + val myMultiOrchestrator = uploadState.multiOrchestrator ?: return@launch + + isUploadingImage = true + + val results = + myMultiOrchestrator.upload( + viewModelScope, + uploadState.caption, + uploadState.contentWarningReason, + MediaCompressor.intToCompressorQuality(uploadState.mediaQualitySlider), + uploadState.selectedServer, + myAccount, + context, + ) + + if (results.allGood) { + results.successful.forEach { + if (it.result is UploadOrchestrator.OrchestratorResult.NIP95Result) { + account?.createNip95(it.result.bytes, headerInfo = it.result.fileHeader, uploadState.caption, uploadState.contentWarningReason) { nip95 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + note?.let { + message = message.insertUrlAtCursor(it.toNostrUri()) + } + + urlPreview = findUrlInMessage() + } + } else if (it.result is UploadOrchestrator.OrchestratorResult.ServerResult) { + iMetaAttachments.add(it.result, uploadState.caption, uploadState.contentWarningReason) + + message = message.insertUrlAtCursor(it.result.url) + urlPreview = findUrlInMessage() + } + } + + uploadState.reset() + } else { + val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct() + + onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n")) + } + + isUploadingImage = false + } + } + + private suspend fun createTemplate(): EventTemplate? { + val channel = channel ?: return null + val accountViewModel = accountViewModel ?: return null + + val tagger = + NewMessageTagger( + message = message.text, + pTags = listOfNotNull(replyTo.value?.author), + eTags = listOfNotNull(replyTo.value), + channelHex = channel.idHex, + dao = accountViewModel, + ) + tagger.run() + + val urls = findURLs(message.text) + val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) + val emojis = findEmoji(message.text, accountViewModel.account.myEmojis.value) + + val channelRelays = channel.relays() + val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + + return if (channel is PublicChatChannel) { + val replyingToEvent = replyTo.value?.toEventHint() + val channelEvent = channel.event + + if (replyingToEvent != null) { + ChannelMessageEvent.reply(tagger.message, replyingToEvent) { + notify(replyingToEvent.toPTag()) + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else if (channelEvent != null) { + val hint = EventHintBundle(channelEvent, channelRelays.firstOrNull()) + ChannelMessageEvent.message(tagger.message, hint) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } else { + ChannelMessageEvent.message(tagger.message, ETag(channel.idHex, channelRelays.firstOrNull())) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + geoHash?.let { geohash(it) } + + emojis(emojis) + imetas(usedAttachments) + } + } + } else if (channel is LiveActivitiesChannel) { + val replyingToEvent = replyTo.value?.toEventHint() + val activity = channel.info + + if (replyingToEvent != null) { + LiveActivitiesChatMessageEvent.reply(tagger.message, replyingToEvent) { + notify(replyingToEvent.toPTag()) + + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else if (activity != null) { + val hint = EventHintBundle(activity, channelRelays.firstOrNull() ?: replyingToEvent?.relay) + + LiveActivitiesChatMessageEvent.message(tagger.message, hint) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } else { + LiveActivitiesChatMessageEvent.message(tagger.message, channel.toATag()) { + hashtags(findHashtags(tagger.message)) + references(findURLs(tagger.message)) + quotes(findNostrUris(tagger.message)) + + emojis(emojis) + imetas(usedAttachments) + } + } + } else { + null + } + } + + fun findEmoji( + message: String, + myEmojiSet: List?, + ): List { + if (myEmojiSet == null) return emptyList() + return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji -> + myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) } + } + } + + open fun cancel() { + message = TextFieldValue("") + + replyTo.value = null + + urlPreview = null + + wantsInvoice = false + wantsZapraiser = false + zapRaiserAmount = null + + wantsForwardZapTo = false + wantsToMarkAsSensitive = false + + wantsToAddGeoHash = 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) + } + } + + 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 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("") + } + + 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() + } + + fun canPost(): Boolean = + message.text.isNotBlank() && + uploadState?.isUploadingImage != true && + !wantsInvoice && + (!wantsZapraiser || zapRaiserAmount != null) && + uploadState?.multiOrchestrator == null + + fun insertAtCursor(newElement: String) { + message = message.insertUrlAtCursor(newElement) + } + + fun locationFlow(): StateFlow { + if (location == null) { + location = Amethyst.instance.locationManager.geohashStateFlow + } + + return location!! + } + + override fun onCleared() { + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } + + fun updateZapPercentage( + index: Int, + sliderValue: Float, + ) { + forwardZapTo.updatePercentage(index, sliderValue) + } + + fun updateZapFromText() { + viewModelScope.launch(Dispatchers.Default) { + val tagger = NewMessageTagger(message.text, emptyList(), emptyList(), null, accountViewModel!!) + tagger.run() + tagger.pTags?.forEach { taggedUser -> + if (!forwardZapTo.items.any { it.key == taggedUser }) { + forwardZapTo.addItem(taggedUser) + } + } + } + } + + fun updateZapRaiserAmount(newAmount: Long?) { + zapRaiserAmount = newAmount + saveDraft() + } + + fun toggleMarkAsSensitive() { + wantsToMarkAsSensitive = !wantsToMarkAsSensitive + saveDraft() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt new file mode 100644 index 000000000..2148f4ce0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery +import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField +import com.vitorpamplona.amethyst.ui.navigation.INav +import com.vitorpamplona.amethyst.ui.note.ShowEmojiSuggestionList +import com.vitorpamplona.amethyst.ui.note.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.EditFieldBorder +import com.vitorpamplona.amethyst.ui.theme.EditFieldLeadingIconModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldModifier +import com.vitorpamplona.amethyst.ui.theme.EditFieldTrailingIconModifier +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +@OptIn(FlowPreview::class) +@Composable +fun EditFieldRow( + channelScreenModel: ChannelNewMessageViewModel, + accountViewModel: AccountViewModel, + onSendNewMessage: () -> Unit, + nav: INav, +) { + channelScreenModel.replyTo.value?.let { + DisplayReplyingToNote(it, accountViewModel, nav) { + channelScreenModel.clearReply() + } + } + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + channelScreenModel.draftTextChanges + .receiveAsFlow() + .debounce(1000) + .collectLatest { + channelScreenModel.sendDraft() + } + } + } + + Column( + modifier = EditFieldModifier, + ) { + ShowUserSuggestionList( + channelScreenModel.userSuggestions.userSuggestions, + channelScreenModel::autocompleteWithUser, + accountViewModel, + ) + + ShowEmojiSuggestionList( + channelScreenModel.emojiSuggestions, + channelScreenModel::autocompleteWithEmoji, + channelScreenModel::autocompleteWithEmojiUrl, + accountViewModel, + ) + + ThinPaddingTextField( + value = channelScreenModel.message, + onValueChange = { channelScreenModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + shape = EditFieldBorder, + modifier = Modifier.fillMaxWidth(), + placeholder = { + Text( + text = stringRes(R.string.reply_here), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + trailingIcon = { + ThinSendButton( + isActive = + channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage, + modifier = EditFieldTrailingIconModifier, + ) { + channelScreenModel.sendPost(onSendNewMessage) + } + }, + leadingIcon = { + SelectFromGallery( + isUploading = channelScreenModel.isUploadingImage, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = EditFieldLeadingIconModifier, + onImageChosen = channelScreenModel::pickedMedia, + ) + }, + colors = + TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt index bc8a45e6d..f3a0d42ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ThinSendButton.kt @@ -38,7 +38,7 @@ fun ThinSendButton( ) { IconButton( enabled = isActive, - modifier = modifier, + // modifier = modifier, onClick = onClick, ) { Icon( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index c82b5255b..66bbc706d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -147,7 +147,7 @@ import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.LevelFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RenderFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.public.ChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ChannelHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.ThinSendButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 09315da25..5527262b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -114,7 +114,9 @@ val HalfStartPadding = Modifier.padding(start = 5.dp) val StdStartPadding = Modifier.padding(start = 10.dp) val StdTopPadding = Modifier.padding(top = 10.dp) val HalfTopPadding = Modifier.padding(top = 5.dp) + val HalfHalfVertPadding = Modifier.padding(vertical = 3.dp) +val HalfHalfHorzModifier = Modifier.padding(horizontal = 3.dp) val HalfPadding = Modifier.padding(5.dp) val StdPadding = Modifier.padding(10.dp) @@ -207,7 +209,7 @@ val CashuCardBorders = Modifier.fillMaxWidth().padding(10.dp).clip(shape = Quote val EditFieldModifier = Modifier.padding(start = 10.dp, end = 10.dp, bottom = 10.dp, top = 5.dp).fillMaxWidth() -val EditFieldTrailingIconModifier = Modifier.height(26.dp).padding(start = 5.dp, end = 0.dp) +val EditFieldTrailingIconModifier = Modifier.padding(start = 5.dp, end = 0.dp) val EditFieldLeadingIconModifier = Modifier.height(32.dp).padding(start = 2.dp) val ZeroPadding = PaddingValues(0.dp) @@ -299,3 +301,8 @@ val ripple24dp = ripple(bounded = false, radius = Size24dp) val defaultTweenDuration = 100 val defaultTweenFloatSpec = tween(durationMillis = defaultTweenDuration) val defaultTweenIntOffsetSpec = tween(durationMillis = defaultTweenDuration) + +val StreamingHeaderModifier = + Modifier + .fillMaxWidth() + .heightIn(min = 50.dp, max = 300.dp) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index af3f876f3..cb9e70b98 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -109,4 +109,21 @@ abstract class NostrSigner( ) as T, ) } + + fun assembleRumor(ev: EventTemplate) = assembleRumor(ev.createdAt, ev.kind, ev.tags, ev.content) + + fun assembleRumor( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ) = EventFactory.create( + id = EventHasher.hashId(pubKey, createdAt, kind, tags, content), + pubKey = pubKey, + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + sig = "", + ) as T } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt index 4c5c418e4..0ae65be00 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip28PublicChat/message/ChannelMessageEvent.kt @@ -23,14 +23,17 @@ package com.vitorpamplona.quartz.nip28PublicChat.message import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.markedETag import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel import com.vitorpamplona.quartz.nip28PublicChat.base.channel import com.vitorpamplona.quartz.nip28PublicChat.base.reply +import com.vitorpamplona.quartz.nip37Drafts.ExposeInDraft import com.vitorpamplona.quartz.utils.TimeUtils @Immutable @@ -42,7 +45,8 @@ class ChannelMessageEvent( content: String, sig: HexKey, ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), - IsInPublicChatChannel { + IsInPublicChatChannel, + ExposeInDraft { override fun channel() = markedRoot() ?: unmarkedRoot() override fun channelId() = channel()?.eventId @@ -51,6 +55,12 @@ class ChannelMessageEvent( override fun unmarkedReplyTos() = super.unmarkedReplyTos().filter { it != channelId() } + override fun exposeInDraft() = + tagArray { + channel()?.let { markedETag(it) } + reply()?.let { markedETag(it) } + } + companion object { const val KIND = 42 const val ALT = "Public chat message" diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt new file mode 100644 index 000000000..64753dd76 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftBuilder.kt @@ -0,0 +1,61 @@ +/** + * 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.quartz.nip37Drafts + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent.Companion.ALT_DESCRIPTION +import com.vitorpamplona.quartz.nip37Drafts.DraftEvent.Companion.KIND +import com.vitorpamplona.quartz.utils.TimeUtils + +class DraftBuilder { + companion object { + fun encryptAndSign( + dTag: String, + draft: T, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (DraftEvent) -> Unit, + ) { + signer.nip44Encrypt(draft.toJson(), signer.pubKey) { encryptedContent -> + val template = + eventTemplate(KIND, encryptedContent, createdAt) { + alt(ALT_DESCRIPTION) + dTag(dTag) + kind(draft.kind) + + if (draft is ExposeInDraft) { + addAll(draft.exposeInDraft()) + } + } + + signer.sign(template) { + it.addToCache(signer.pubKey, draft) + onReady(it) + } + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt index a0de590fc..8181069da 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/DraftEvent.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip01Core.tags.dTags.dTag +import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent @@ -121,6 +123,7 @@ class DraftEvent( companion object { const val KIND = 31234 + const val ALT_DESCRIPTION = "Draft Event" fun createAddressTag( pubKey: HexKey, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt new file mode 100644 index 000000000..12710bb4b --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip37Drafts/ExposeInDraft.kt @@ -0,0 +1,27 @@ +/** + * 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.quartz.nip37Drafts + +import com.vitorpamplona.quartz.nip01Core.core.TagArray + +interface ExposeInDraft { + fun exposeInDraft(): TagArray +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt index 65f82c8c3..5444f2f83 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip53LiveActivities/chat/LiveActivitiesChatMessageEvent.kt @@ -23,16 +23,20 @@ package com.vitorpamplona.quartz.nip53LiveActivities.chat import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.tagArray import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag +import com.vitorpamplona.quartz.nip01Core.tags.addressables.aTag import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent +import com.vitorpamplona.quartz.nip10Notes.tags.markedETag import com.vitorpamplona.quartz.nip18Reposts.quotes.QTag +import com.vitorpamplona.quartz.nip37Drafts.ExposeInDraft import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -47,7 +51,8 @@ class LiveActivitiesChatMessageEvent( ) : BaseThreadedEvent(id, pubKey, createdAt, KIND, tags, content, sig), EventHintProvider, PubKeyHintProvider, - AddressHintProvider { + AddressHintProvider, + ExposeInDraft { override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) override fun eventHints() = tags.mapNotNull(ETag::parseAsHint) + tags.mapNotNull(QTag::parseEventAsHint) @@ -64,6 +69,12 @@ class LiveActivitiesChatMessageEvent( override fun unmarkedReplyTos() = super.markedReplyTos().minus(activityHex() ?: "") + override fun exposeInDraft() = + tagArray { + activity()?.let { aTag(it) } + reply()?.let { markedETag(it) } + } + companion object { const val KIND = 1311 const val ALT = "Live activity chat message"