Starts the migration of public chats to the new structure

Starts the migration of drafts to the new structure
This commit is contained in:
Vitor Pamplona 2025-03-10 19:33:12 -04:00
parent 58b6be73ee
commit fe55ee1818
53 changed files with 3005 additions and 1479 deletions

View File

@ -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<String>,
) {
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<RelaySetupInfo>,
@ -2284,6 +2306,29 @@ class Account(
}
}
fun <T : Event> signAndSendPrivately(
template: EventTemplate<T>,
relayList: List<String>,
) {
signer.sign(template) {
LocalCache.justConsume(it, null)
Amethyst.instance.client.sendPrivately(it, relayList = convertRelayList(relayList))
}
}
fun <T : Event> signAndSend(
template: EventTemplate<T>,
relayList: List<RelaySetupInfo>,
broadcastNotes: Set<Note>,
) {
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 <T : Event> signAndSend(
draftTag: String?,
template: EventTemplate<T>,
@ -2357,6 +2402,16 @@ class Account(
signAndSend(draftTag, template, relayList, broadcastNotes)
}
fun createAndSendDraft(
draftTag: String,
template: EventTemplate<out Event>,
) {
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<String>): List<RelaySetupInfoToConnect> =
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) }

View File

@ -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 -> {

View File

@ -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,
)
}
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
)
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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 <T : Event> createRumor(template: EventTemplate<T>) = account.signer.assembleRumor<T>(template)
fun retrieveRelayDocument(
dirtyUrl: String,
onInfo: (Nip11RelayInformation) -> Unit,

View File

@ -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 {

View File

@ -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,

View File

@ -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()

View File

@ -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,

View File

@ -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 =

View File

@ -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,
)

View File

@ -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) {

View File

@ -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<String?>(null)
// Images and Videos
var multiOrchestrator by mutableStateOf<MultiOrchestrator?>(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
}
}
}

View File

@ -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,

View File

@ -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,
)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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,
)
}
}

View File

@ -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)
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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,
)
}

View File

@ -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)
}
}
}

View File

@ -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),
)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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,
)
}
}
}

View File

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.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

View File

@ -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)
}
}
}
}

View File

@ -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,
)
}
}

View File

@ -0,0 +1,195 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.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<ImmutableList<Pair<ParticipantTag, User>>>(
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)
}
}
}

View File

@ -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)
}
}
}

View File

@ -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,
)
}
}
}
}
}
}
}

View File

@ -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,
)
}

View File

@ -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,
)
}

View File

@ -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<Note?>(null)
var uploadState by mutableStateOf<ChatFileUploadState?>(null)
val iMetaAttachments = IMetaAttachments()
var nip95attachments by mutableStateOf<List<Pair<FileStorageEvent, FileStorageHeaderEvent>>>(emptyList())
var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null)
var isUploadingImage by mutableStateOf(false)
val userSuggestions = UserSuggestions()
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
val emojiSearch: MutableStateFlow<String> = MutableStateFlow("")
val emojiSuggestions: StateFlow<List<Account.EmojiMedia>> by lazy {
account!!
.myEmojis
.combine(emojiSearch) { list, search ->
if (search.length == 1) {
list
} else if (search.isNotEmpty()) {
val code = search.removePrefix(":")
list.filter { it.code.startsWith(code) }
} else {
emptyList()
}
}.flowOn(Dispatchers.Default)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
emptyList(),
)
}
// Invoices
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
// Forward Zap to
var wantsForwardZapTo by mutableStateOf(false)
var forwardZapTo by mutableStateOf<Split<User>>(Split())
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
// NSFW, Sensitive
var wantsToMarkAsSensitive by mutableStateOf(false)
// GeoHash
var wantsToAddGeoHash by mutableStateOf(false)
var location: StateFlow<LocationState.LocationResult>? = null
var wantsExclusiveGeoPost by mutableStateOf(false)
// ZapRaiser
var canAddZapRaiser by mutableStateOf(false)
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
val draftTextChanges = kotlinx.coroutines.channels.Channel<String>(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<SelectedMedia>) {
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<out Event>? {
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<ChannelMessageEvent>()
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<LiveActivitiesChatMessageEvent>()
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<Account.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.url.url) }
}
}
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<LocationState.LocationResult> {
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()
}
}

View File

@ -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),
)
}
}

View File

@ -38,7 +38,7 @@ fun ThinSendButton(
) {
IconButton(
enabled = isActive,
modifier = modifier,
// modifier = modifier,
onClick = onClick,
) {
Icon(

View File

@ -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

View File

@ -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<Float>(durationMillis = defaultTweenDuration)
val defaultTweenIntOffsetSpec = tween<IntOffset>(durationMillis = defaultTweenDuration)
val StreamingHeaderModifier =
Modifier
.fillMaxWidth()
.heightIn(min = 50.dp, max = 300.dp)

View File

@ -109,4 +109,21 @@ abstract class NostrSigner(
) as T,
)
}
fun <T : Event> assembleRumor(ev: EventTemplate<T>) = assembleRumor<T>(ev.createdAt, ev.kind, ev.tags, ev.content)
fun <T : Event> assembleRumor(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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
}

View File

@ -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<ChannelMessageEvent> {
channel()?.let { markedETag(it) }
reply()?.let { markedETag(it) }
}
companion object {
const val KIND = 42
const val ALT = "Public chat message"

View File

@ -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 <T : Event> encryptAndSign(
dTag: String,
draft: T,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
signer.nip44Encrypt(draft.toJson(), signer.pubKey) { encryptedContent ->
val template =
eventTemplate<DraftEvent>(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)
}
}
}
}
}

View File

@ -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,

View File

@ -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
}

View File

@ -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<LiveActivitiesChatMessageEvent> {
activity()?.let { aTag(it) }
reply()?.let { markedETag(it) }
}
companion object {
const val KIND = 1311
const val ALT = "Live activity chat message"