From 3eb6983a94b79e6cf5bee0bdfacbf774c878e5d5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 28 Feb 2024 17:42:06 -0500 Subject: [PATCH] Support for Kind 1 Edits --- .../vitorpamplona/amethyst/model/Account.kt | 20 + .../amethyst/model/LocalCache.kt | 33 ++ .../com/vitorpamplona/amethyst/model/Note.kt | 5 +- .../service/NostrSingleEventDataSource.kt | 2 + .../amethyst/ui/actions/EditPostView.kt | 499 ++++++++++++++++++ .../amethyst/ui/actions/EditPostViewModel.kt | 362 +++++++++++++ .../amethyst/ui/note/BadgeCompose.kt | 2 +- .../amethyst/ui/note/MessageSetCompose.kt | 2 +- .../amethyst/ui/note/MultiSetCompose.kt | 2 +- .../amethyst/ui/note/NoteCompose.kt | 86 ++- .../amethyst/ui/note/UserProfilePicture.kt | 24 + .../amethyst/ui/screen/ThreadFeedView.kt | 30 +- .../ui/screen/loggedIn/AccountViewModel.kt | 9 + .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/VideoScreen.kt | 8 +- app/src/main/res/values/strings.xml | 4 + .../quartz/events/EventFactory.kt | 1 + .../events/TextNoteModificationEvent.kt | 52 ++ 18 files changed, 1119 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt create mode 100644 quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index c7421c35f..ae52d71f3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -89,6 +89,7 @@ import com.vitorpamplona.quartz.events.Response import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.signers.NostrSigner @@ -1405,6 +1406,25 @@ class Account( } } + fun sendEdit( + message: String, + originalNote: Note, + relayList: List? = null, + ) { + if (!isWriteable()) return + + val idHex = originalNote.event?.id() ?: return + + TextNoteModificationEvent.create( + content = message, + eventId = idHex, + signer = signer, + ) { + Client.send(it, relayList = relayList) + LocalCache.justConsume(it, null) + } + } + fun sendPoll( message: String, replyTo: List?, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f366531a5..5e4d860e5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -98,6 +98,7 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent @@ -1384,6 +1385,26 @@ object LocalCache { refreshObservers(note) } + fun consume( + event: TextNoteModificationEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + fun consume( event: HighlightEvent, relay: Relay?, @@ -1695,6 +1716,17 @@ object LocalCache { return minTime } + suspend fun findLatestModificationForNote(note: Note): List { + checkNotInMainThread() + val time = TimeUtils.now() + + return noteListCache.filter { item -> + val noteEvent = item.event + + noteEvent is TextNoteModificationEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) + } + } + fun cleanObservers() { noteListCache.forEach { it.clearLive() } @@ -2048,6 +2080,7 @@ object LocalCache { } is StatusEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay) + is TextNoteModificationEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay) is WikiNoteEvent -> consume(event, relay) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 1ab49b3f4..77f380b3c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -948,6 +948,7 @@ class NoteLiveSet(u: Note) { val innerRelays = NoteBundledRefresherLiveData(u) val innerZaps = NoteBundledRefresherLiveData(u) val innerOts = NoteBundledRefresherLiveData(u) + val innerModifications = NoteBundledRefresherLiveData(u) val metadata = innerMetadata.map { it } val reactions = innerReactions.map { it } @@ -1001,8 +1002,9 @@ class NoteLiveSet(u: Note) { hasReactions.hasObservers() || replyCount.hasObservers() || reactionCount.hasObservers() || - boostCount.hasObservers() + boostCount.hasObservers() || innerOts.hasObservers() || + innerModifications.hasObservers() } fun destroy() { @@ -1014,6 +1016,7 @@ class NoteLiveSet(u: Note) { innerRelays.destroy() innerZaps.destroy() innerOts.destroy() + innerModifications.destroy() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index e29566892..618dcbeb9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.events.ReactionEvent import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TextNoteModificationEvent object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { private var eventsToWatch = setOf() @@ -136,6 +137,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { LnZapEvent.KIND, PollNoteEvent.KIND, OtsEvent.KIND, + TextNoteModificationEvent.KIND, ), tags = mapOf("e" to it.map { it.idHex }), since = findMinimumEOSEs(it), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt new file mode 100644 index 000000000..1d35c13f0 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -0,0 +1,499 @@ +/** + * 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 android.widget.Toast +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CurrencyBitcoin +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Alignment.Companion.CenterVertically +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.ui.components.BechLink +import com.vitorpamplona.amethyst.ui.components.InvoiceRequest +import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview +import com.vitorpamplona.amethyst.ui.components.VideoView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditPostView( + onClose: () -> Unit, + edit: Note, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val postViewModel: EditPostViewModel = viewModel() + + val context = LocalContext.current + + val scrollState = rememberScrollState() + val scope = rememberCoroutineScope() + var showRelaysDialog by remember { mutableStateOf(false) } + var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() } + + LaunchedEffect(Unit) { + postViewModel.load(edit, accountViewModel) + + launch(Dispatchers.IO) { + postViewModel.imageUploadingError.collect { error -> + withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() } + } + } + } + + DisposableEffect(Unit) { + NostrSearchEventOrUserDataSource.start() + + onDispose { + NostrSearchEventOrUserDataSource.clear() + NostrSearchEventOrUserDataSource.stop() + } + } + + Dialog( + onDismissRequest = { onClose() }, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = false, + decorFitsSystemWindows = false, + ), + ) { + if (showRelaysDialog) { + RelaySelectionDialog( + preSelectedList = relayList, + onClose = { showRelaysDialog = false }, + onPost = { relayList = it }, + accountViewModel = accountViewModel, + nav = nav, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = StdHorzSpacer) + + Box { + IconButton( + modifier = Modifier.align(Alignment.Center), + onClick = { showRelaysDialog = true }, + ) { + Icon( + painter = painterResource(R.drawable.relays), + contentDescription = stringResource(id = R.string.relay_list_selector), + modifier = Modifier.height(25.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + PostButton( + onPost = { + postViewModel.sendPost(relayList = relayList) + scope.launch { + delay(100) + onClose() + } + }, + isActive = postViewModel.canPost(), + ) + } + }, + navigationIcon = { + Row { + Spacer(modifier = StdHorzSpacer) + CloseButton( + onPress = { + postViewModel.cancel() + scope.launch { + delay(100) + onClose() + } + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding( + start = Size10dp, + top = pad.calculateTopPadding(), + end = Size10dp, + bottom = pad.calculateBottomPadding(), + ) + .fillMaxSize(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(), + ) { + Column( + modifier = + Modifier + .imePadding() + .weight(1f), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + MessageField(postViewModel) + + val myUrlPreview = postViewModel.urlPreview + if (myUrlPreview != null) { + Row(modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp)) { + if (RichTextParser.isValidURL(myUrlPreview)) { + if (RichTextParser.isImageUrl(myUrlPreview)) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = + Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = QuoteBorder) + .border( + 1.dp, + MaterialTheme.colorScheme.subtleBorder, + QuoteBorder, + ), + ) + } else if (RichTextParser.isVideoUrl(myUrlPreview)) { + VideoView( + myUrlPreview, + roundedCorner = true, + accountViewModel = accountViewModel, + ) + } else { + LoadUrlPreview(myUrlPreview, myUrlPreview, accountViewModel) + } + } else if (RichTextParser.startsWithNIP19Scheme(myUrlPreview)) { + val bgColor = MaterialTheme.colorScheme.background + val backgroundColor = remember { mutableStateOf(bgColor) } + + BechLink( + myUrlPreview, + true, + backgroundColor, + accountViewModel, + nav, + ) + } else if (RichTextParser.isUrlWithoutScheme(myUrlPreview)) { + LoadUrlPreview("https://$myUrlPreview", myUrlPreview, accountViewModel) + } + } + } + + val url = postViewModel.contentToAddUrl + if (url != null) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + ImageVideoDescription( + url, + accountViewModel.account.defaultFileServer, + onAdd = { alt, server, sensitiveContent -> + postViewModel.upload(url, alt, sensitiveContent, false, server, context) + if (!server.isNip95) { + accountViewModel.account.changeDefaultFileServer(server.server) + } + }, + onCancel = { postViewModel.contentToAddUrl = null }, + onError = { scope.launch { postViewModel.imageUploadingError.emit(it) } }, + accountViewModel = accountViewModel, + ) + } + } + + val user = postViewModel.account?.userProfile() + val lud16 = user?.info?.lnAddress() + + if (lud16 != null && postViewModel.wantsInvoice) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), + ) { + Column(Modifier.fillMaxWidth()) { + InvoiceRequest( + lud16, + user.pubkeyHex, + accountViewModel.account, + stringResource(id = R.string.lightning_invoice), + stringResource(id = R.string.lightning_create_and_add_invoice), + onSuccess = { + postViewModel.message = + TextFieldValue(postViewModel.message.text + "\n\n" + it) + postViewModel.wantsInvoice = false + }, + onClose = { postViewModel.wantsInvoice = false }, + onError = { title, message -> accountViewModel.toast(title, message) }, + ) + } + } + } + } + } + + ShowUserSuggestionListForEdit( + postViewModel, + accountViewModel, + modifier = Modifier.heightIn(0.dp, 300.dp), + ) + + BottomRowActions(postViewModel) + } + } + } + } + } +} + +@Composable +fun ShowUserSuggestionListForEdit( + editPostViewModel: EditPostViewModel, + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier.heightIn(0.dp, 200.dp), +) { + val userSuggestions = editPostViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = + PaddingValues( + top = 10.dp, + ), + modifier = modifier, + ) { + itemsIndexed( + userSuggestions, + key = { _, item -> item.pubkeyHex }, + ) { _, item -> + UserLine(item, accountViewModel) { editPostViewModel.autocompleteWithUser(item) } + } + } + } +} + +@Composable +private fun BottomRowActions(postViewModel: EditPostViewModel) { + val scrollState = rememberScrollState() + + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .fillMaxWidth() + .height(50.dp), + verticalAlignment = CenterVertically, + ) { + UploadFromGallery( + isUploading = postViewModel.isUploadingImage, + tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier, + ) { + postViewModel.selectImage(it) + } + + if (postViewModel.canAddInvoice) { + AddLnInvoiceButton(postViewModel.wantsInvoice) { + postViewModel.wantsInvoice = !postViewModel.wantsInvoice + } + } + } +} + +@Composable +private fun MessageField(postViewModel: EditPostViewModel) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + launch { + delay(200) + focusRequester.requestFocus() + } + } + + OutlinedTextField( + value = postViewModel.message, + onValueChange = { postViewModel.updateMessage(it) }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + modifier = + Modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(8.dp), + ) + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + keyboardController?.show() + } + }, + placeholder = { + Text( + text = stringResource(R.string.what_s_on_your_mind), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} + +@Composable +private fun AddLnInvoiceButton( + isLnInvoiceActive: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = { onClick() }, + ) { + if (!isLnInvoiceActive) { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + contentDescription = stringResource(id = R.string.add_bitcoin_invoice), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onBackground, + ) + } else { + Icon( + imageVector = Icons.Default.CurrencyBitcoin, + contentDescription = stringResource(id = R.string.cancel_bitcoin_invoice), + modifier = Modifier.size(20.dp), + tint = BitcoinOrange, + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt new file mode 100644 index 000000000..99c0b6314 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -0,0 +1,362 @@ +/** + * 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 android.content.Context +import android.net.Uri +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.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.RichTextParser +import com.vitorpamplona.amethyst.commons.insertUrlAtCursor +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.FileHeader +import com.vitorpamplona.amethyst.service.Nip96Uploader +import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource +import com.vitorpamplona.amethyst.service.relays.Relay +import com.vitorpamplona.amethyst.ui.components.MediaCompressor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.events.FileHeaderEvent +import com.vitorpamplona.quartz.events.FileStorageEvent +import com.vitorpamplona.quartz.events.FileStorageHeaderEvent +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch + +@Stable +open class EditPostViewModel() : ViewModel() { + var accountViewModel: AccountViewModel? = null + var account: Account? = null + + var editedFromNote: Note? = null + + var nip94attachments by mutableStateOf>(emptyList()) + var nip95attachments by + mutableStateOf>>(emptyList()) + + var message by mutableStateOf(TextFieldValue("")) + var urlPreview by mutableStateOf(null) + var isUploadingImage by mutableStateOf(false) + val imageUploadingError = + MutableSharedFlow(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + var userSuggestions by mutableStateOf>(emptyList()) + var userSuggestionAnchor: TextRange? = null + var userSuggestionsMainMessage: UserSuggestionAnchor? = null + + // Images and Videos + var contentToAddUrl by mutableStateOf(null) + + // Invoices + var canAddInvoice by mutableStateOf(false) + var wantsInvoice by mutableStateOf(false) + + open fun load( + edit: Note, + accountViewModel: AccountViewModel, + ) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + + canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null + contentToAddUrl = null + + message = TextFieldValue(edit.event?.content() ?: "") + urlPreview = findUrlInMessage() + + editedFromNote = edit + } + + fun sendPost(relayList: List? = null) { + viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } + } + + suspend fun innerSendPost(relayList: List? = null) { + if (accountViewModel == null) { + cancel() + return + } + + nip95attachments.forEach { + account?.sendNip95(it.first, it.second, relayList) + } + + account?.sendEdit( + message = message.text, + originalNote = editedFromNote!!, + relayList = relayList, + ) + + cancel() + } + + fun upload( + galleryUri: Uri, + alt: String?, + sensitiveContent: Boolean, + isPrivate: Boolean = false, + server: ServerOption, + context: Context, + ) { + isUploadingImage = true + contentToAddUrl = null + + val contentResolver = context.contentResolver + val contentType = contentResolver.getType(galleryUri) + + viewModelScope.launch(Dispatchers.IO) { + MediaCompressor() + .compress( + galleryUri, + contentType, + context.applicationContext, + onReady = { fileUri, contentType, size -> + if (server.isNip95) { + contentResolver.openInputStream(fileUri)?.use { + createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent) + } + } else { + viewModelScope.launch(Dispatchers.IO) { + try { + val result = + Nip96Uploader(account) + .uploadImage( + uri = fileUri, + contentType = contentType, + size = size, + alt = alt, + sensitiveContent = if (sensitiveContent) "" else null, + server = server.server, + contentResolver = contentResolver, + onProgress = {}, + ) + + createNIP94Record( + uploadingResult = result, + localContentType = contentType, + alt = alt, + sensitiveContent = sensitiveContent, + ) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e( + "ImageUploader", + "Failed to upload ${e.message}", + e, + ) + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload: ${e.message}") + } + } + } + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit(it) } + }, + ) + } + } + + open fun cancel() { + message = TextFieldValue("") + + editedFromNote = null + + contentToAddUrl = null + urlPreview = null + isUploadingImage = false + + wantsInvoice = false + + userSuggestions = emptyList() + userSuggestionAnchor = null + userSuggestionsMainMessage = null + + NostrSearchEventOrUserDataSource.clear() + } + + open fun findUrlInMessage(): String? { + return message.text.split('\n').firstNotNullOfOrNull { paragraph -> + paragraph.split(' ').firstOrNull { word: String -> + RichTextParser.isValidURL(word) || RichTextParser.isUrlWithoutScheme(word) + } + } + } + + open fun updateMessage(it: TextFieldValue) { + message = it + urlPreview = findUrlInMessage() + + if (it.selection.collapsed) { + val lastWord = + it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + userSuggestionsMainMessage = UserSuggestionAnchor.MAIN_MESSAGE + if (lastWord.startsWith("@") && lastWord.length > 2) { + NostrSearchEventOrUserDataSource.search(lastWord.removePrefix("@")) + viewModelScope.launch(Dispatchers.IO) { + userSuggestions = + LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + .sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })) + .reversed() + } + } else { + NostrSearchEventOrUserDataSource.clear() + userSuggestions = emptyList() + } + } + } + + open fun autocompleteWithUser(item: User) { + userSuggestionAnchor?.let { + if (userSuggestionsMainMessage == UserSuggestionAnchor.MAIN_MESSAGE) { + val lastWord = + message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkeyNpub()}" + + message = + TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length), + ) + } + + userSuggestionAnchor = null + userSuggestionsMainMessage = null + userSuggestions = emptyList() + } + } + + fun canPost(): Boolean { + return message.text.isNotBlank() && + !isUploadingImage && + !wantsInvoice && + contentToAddUrl == null + } + + suspend fun createNIP94Record( + uploadingResult: Nip96Uploader.PartialEvent, + localContentType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + // Images don't seem to be ready immediately after upload + val imageUrl = uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "url" }?.get(1) + val remoteMimeType = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "m" }?.get(1)?.ifBlank { null } + val originalHash = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "ox" }?.get(1)?.ifBlank { null } + val dim = + uploadingResult.tags?.firstOrNull { it.size > 1 && it[0] == "dim" }?.get(1)?.ifBlank { null } + val magnet = + uploadingResult.tags + ?.firstOrNull { it.size > 1 && it[0] == "magnet" } + ?.get(1) + ?.ifBlank { null } + + if (imageUrl.isNullOrBlank()) { + Log.e("ImageDownload", "Couldn't download image from server") + cancel() + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + return + } + + FileHeader.prepare( + fileUrl = imageUrl, + mimeType = remoteMimeType ?: localContentType, + dimPrecomputed = dim, + onReady = { header: FileHeader -> + account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event -> + isUploadingImage = false + nip94attachments = nip94attachments + event + + message = message.insertUrlAtCursor(imageUrl) + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) + } + + fun createNIP95Record( + bytes: ByteArray, + mimeType: String?, + alt: String?, + sensitiveContent: Boolean, + ) { + if (bytes.size > 80000) { + viewModelScope.launch { + imageUploadingError.emit("Media is too big for NIP-95") + isUploadingImage = false + } + return + } + + viewModelScope.launch(Dispatchers.IO) { + FileHeader.prepare( + bytes, + mimeType, + null, + onReady = { + account?.createNip95(bytes, headerInfo = it, alt, sensitiveContent) { nip95 -> + nip95attachments = nip95attachments + nip95 + val note = nip95.let { it1 -> account?.consumeNip95(it1.first, it1.second) } + + isUploadingImage = false + + note?.let { + message = message.insertUrlAtCursor("nostr:" + it.toNEvent()) + } + + urlPreview = findUrlInMessage() + } + }, + onError = { + isUploadingImage = false + viewModelScope.launch { imageUploadingError.emit("Failed to upload the image / video") } + }, + ) + } + } + + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt index 007f5ea66..fe987ca47 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BadgeCompose.kt @@ -167,7 +167,7 @@ fun BadgeCompose( tint = MaterialTheme.colorScheme.placeholderText, ) - NoteDropDownMenu(note, popupExpanded, accountViewModel) + NoteDropDownMenu(note, popupExpanded, accountViewModel, nav) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt index 3e75719d9..17e95d00c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MessageSetCompose.kt @@ -129,7 +129,7 @@ fun MessageSetCompose( nav = nav, ) - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + NoteDropDownMenu(baseNote, popupExpanded, accountViewModel, nav) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 3226d05cc..5a8cb21ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -166,7 +166,7 @@ fun MultiSetCompose( nav = nav, ) - NoteDropDownMenu(baseNote, popupExpanded, accountViewModel) + NoteDropDownMenu(baseNote, popupExpanded, accountViewModel, nav) } Divider( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 2cdb08945..ee4584be8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -60,16 +60,16 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.Alignment.Companion.Center import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -85,7 +85,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.capitalize import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -785,7 +784,7 @@ fun LongCommunityHeader( ) Spacer(DoubleHorzSpacer) NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f)) - MoreOptionsButton(baseNote, accountViewModel) + MoreOptionsButton(baseNote, accountViewModel, nav) } } @@ -1091,6 +1090,12 @@ fun InnerNoteWithReactions( } } +@Stable +class EditState( + val showOriginal: MutableState = mutableStateOf(false), + val modificationsInOrder: MutableState> = mutableStateOf(emptyList()), +) + @Composable private fun NoteBody( baseNote: Note, @@ -1103,9 +1108,19 @@ private fun NoteBody( accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { + val editState by + produceState(initialValue = EditState(), key1 = baseNote) { + accountViewModel.findModificationEventsForNote(baseNote) { newModifications -> + if (value.modificationsInOrder.value != newModifications) { + value.modificationsInOrder.value = newModifications + } + } + } + FirstUserInfoRow( baseNote = baseNote, showAuthorPicture = showAuthorPicture, + editState = editState, accountViewModel = accountViewModel, nav = nav, ) @@ -1133,12 +1148,13 @@ private fun NoteBody( } RenderNoteRow( - baseNote, - backgroundColor, - makeItShort, - canPreview, - accountViewModel, - nav, + baseNote = baseNote, + backgroundColor = backgroundColor, + makeItShort = makeItShort, + canPreview = canPreview, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, ) val noteEvent = baseNote.event @@ -1155,6 +1171,7 @@ private fun RenderNoteRow( backgroundColor: MutableState, makeItShort: Boolean, canPreview: Boolean, + editState: EditState, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -1287,6 +1304,7 @@ private fun RenderNoteRow( makeItShort, canPreview, backgroundColor, + editState, accountViewModel, nav, ) @@ -1343,19 +1361,29 @@ fun RenderTextEvent( makeItShort: Boolean, canPreview: Boolean, backgroundColor: MutableState, + editState: EditState, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { - LoadDecryptedContent(note, accountViewModel) { body -> + LoadDecryptedContent( + note, + accountViewModel, + ) { body -> val eventContent by remember(note.event) { derivedStateOf { val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } + val newBody = + if (editState.showOriginal.value || editState.modificationsInOrder.value.isEmpty()) { + body + } else { + editState.modificationsInOrder.value.firstOrNull()?.event?.content() ?: body + } - if (!subject.isNullOrBlank() && !body.split("\n")[0].contains(subject)) { - "### $subject\n$body" + if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { + "### $subject\n$newBody" } else { - body + newBody } } } @@ -2789,6 +2817,7 @@ fun DisplayLocation( fun FirstUserInfoRow( baseNote: Note, showAuthorPicture: Boolean, + editState: EditState, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -2823,12 +2852,37 @@ fun FirstUserInfoRow( DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } + if (!editState.modificationsInOrder.value.isEmpty()) { + DisplayEditStatus(editState.showOriginal) + } + TimeAgo(baseNote) - MoreOptionsButton(baseNote, accountViewModel) + MoreOptionsButton(baseNote, accountViewModel, nav) } } +@Composable +fun DisplayEditStatus(showOriginal: MutableState) { + ClickableText( + text = + if (showOriginal.value) { + buildAnnotatedString { append(stringResource(id = R.string.original)) } + } else { + buildAnnotatedString { append(stringResource(id = R.string.edited)) } + }, + onClick = { showOriginal.value = !showOriginal.value }, + style = + LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.placeholderText, + fontSize = Font14SP, + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + modifier = HalfStartPadding, + ) +} + @Composable private fun BoostedMark() { Text( @@ -2844,6 +2898,7 @@ private fun BoostedMark() { fun MoreOptionsButton( baseNote: Note, accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { val popupExpanded = remember { mutableStateOf(false) } val enablePopup = remember { { popupExpanded.value = true } } @@ -2858,6 +2913,7 @@ fun MoreOptionsButton( baseNote, popupExpanded, accountViewModel, + nav, ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index b49ef2786..965aa9394 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -66,6 +66,7 @@ import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.actions.EditPostView import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -455,6 +456,7 @@ fun NoteDropDownMenu( note: Note, popupExpanded: MutableState, accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { var reportDialogShowing by remember { mutableStateOf(false) } @@ -473,6 +475,20 @@ fun NoteDropDownMenu( val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } } + val wantsToEditPost = + remember { + mutableStateOf(false) + } + + if (wantsToEditPost.value) { + EditPostView( + onClose = { wantsToEditPost.value = false }, + edit = note, + accountViewModel = accountViewModel, + nav = nav, + ) + } + DropdownMenu( expanded = popupExpanded.value, onDismissRequest = onDismiss, @@ -551,6 +567,14 @@ fun NoteDropDownMenu( }, ) Divider() + if (state.isLoggedUser) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit_post)) }, + onClick = { + wantsToEditPost.value = true + }, + ) + } DropdownMenuItem( text = { Text(stringResource(R.string.broadcast)) }, onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 2d3153de5..f65619417 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -58,6 +58,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -102,11 +103,13 @@ import com.vitorpamplona.amethyst.ui.note.AudioTrackHeader import com.vitorpamplona.amethyst.ui.note.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.CreateImageHeader +import com.vitorpamplona.amethyst.ui.note.DisplayEditStatus import com.vitorpamplona.amethyst.ui.note.DisplayHighlight import com.vitorpamplona.amethyst.ui.note.DisplayLocation import com.vitorpamplona.amethyst.ui.note.DisplayOts import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet +import com.vitorpamplona.amethyst.ui.note.EditState import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.HiddenNote @@ -120,6 +123,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay import com.vitorpamplona.amethyst.ui.note.ReactionsRow import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack +import com.vitorpamplona.amethyst.ui.note.RenderFhirResource import com.vitorpamplona.amethyst.ui.note.RenderGitPatchEvent import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent @@ -156,6 +160,7 @@ import com.vitorpamplona.quartz.events.CommunityDefinitionEvent import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent import com.vitorpamplona.quartz.events.EmojiPackEvent import com.vitorpamplona.quartz.events.Event +import com.vitorpamplona.quartz.events.FhirResourceEvent import com.vitorpamplona.quartz.events.FileHeaderEvent import com.vitorpamplona.quartz.events.FileStorageHeaderEvent import com.vitorpamplona.quartz.events.GenericRepostEvent @@ -372,6 +377,15 @@ fun NoteMaster( onClick = { showHiddenNote = true }, ) } else { + val editState by + produceState(initialValue = EditState(), key1 = baseNote) { + accountViewModel.findModificationEventsForNote(baseNote) { newModifications -> + if (value.modificationsInOrder.value != newModifications) { + value.modificationsInOrder.value = newModifications + } + } + } + Column( modifier .fillMaxWidth() @@ -407,6 +421,10 @@ fun NoteMaster( DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } + if (!editState.modificationsInOrder.value.isEmpty()) { + DisplayEditStatus(editState.showOriginal) + } + Text( timeAgo(note.createdAt(), context = context), color = MaterialTheme.colorScheme.placeholderText, @@ -424,7 +442,7 @@ fun NoteMaster( tint = MaterialTheme.colorScheme.placeholderText, ) - NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel) + NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel, nav) } } @@ -532,6 +550,8 @@ fun NoteMaster( accountViewModel, nav, ) + } else if (noteEvent is FhirResourceEvent) { + RenderFhirResource(baseNote, accountViewModel, nav) } else if (noteEvent is GitRepositoryEvent) { RenderGitRepositoryEvent(baseNote, accountViewModel, nav) } else if (noteEvent is GitPatchEvent) { @@ -577,6 +597,7 @@ fun NoteMaster( false, canPreview, backgroundColor, + editState, accountViewModel, nav, ) @@ -829,6 +850,11 @@ private fun RenderWikiHeaderForThreadPreview() { val accountViewModel = mockAccountViewModel() val nav: (String) -> Unit = {} + val editState by + remember { + mutableStateOf(EditState()) + } + runBlocking { withContext(Dispatchers.IO) { LocalCache.justConsume(event, null) @@ -851,6 +877,7 @@ private fun RenderWikiHeaderForThreadPreview() { false, true, backgroundColor, + editState, accountViewModel, nav, ) @@ -870,6 +897,7 @@ private fun RenderWikiHeaderForThreadPreview() { false, true, backgroundColor, + editState, accountViewModel, nav, ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 17ee736cc..f18c22e44 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -932,6 +932,15 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } + suspend fun findModificationEventsForNote( + note: Note, + onResult: (List) -> Unit, + ) { + withContext(Dispatchers.IO) { + onResult(LocalCache.findLatestModificationForNote(note)) + } + } + private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? { return LocalCache.checkGetOrCreateChannel(key) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 0a586fd87..f24fca756 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -865,7 +865,7 @@ fun LongChannelHeader( ) Spacer(DoubleHorzSpacer) NormalTimeAgo(note, remember { Modifier.weight(1f) }) - MoreOptionsButton(note, accountViewModel) + MoreOptionsButton(note, accountViewModel, nav) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 24ff01a66..9f3e9136e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -363,12 +363,12 @@ private fun RenderAuthorInformation( ) { Row(verticalAlignment = Alignment.CenterVertically) { NoteUsernameDisplay(note, remember { Modifier.weight(1f) }) - VideoUserOptionAction(note, accountViewModel) + VideoUserOptionAction(note, accountViewModel, nav) } Row(verticalAlignment = Alignment.CenterVertically) { ObserveDisplayNip05Status( - remember { note.author!! }, - remember { Modifier.weight(1f) }, + note.author!!, + Modifier.weight(1f), accountViewModel, nav = nav, ) @@ -387,6 +387,7 @@ private fun RenderAuthorInformation( private fun VideoUserOptionAction( note: Note, accountViewModel: AccountViewModel, + nav: (String) -> Unit, ) { val popupExpanded = remember { mutableStateOf(false) } val enablePopup = remember { { popupExpanded.value = true } } @@ -406,6 +407,7 @@ private fun VideoUserOptionAction( note, popupExpanded, accountViewModel, + nav, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d36dbb21a..f56c9324b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,6 +50,8 @@ View count Boost boosted + edited + original Quote Fork New Amount in Sats @@ -790,4 +792,6 @@ Timestamp Proof There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time. + + Edit Post diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 951901e4e..306277d04 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -126,6 +126,7 @@ class EventFactory { SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig) StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) + TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt new file mode 100644 index 000000000..377dbc91d --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt @@ -0,0 +1,52 @@ +/** + * 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.events + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TextNoteModificationEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 1010 + const val ALT = "Content Change Event" + + fun create( + content: String, + eventId: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TextNoteModificationEvent) -> Unit, + ) { + val tags = arrayOf(arrayOf("e", eventId), arrayOf("alt", CalendarDateSlotEvent.ALT)) + signer.sign(createdAt, KIND, tags, content, onReady) + } + } +}