From 21a18cfa38f0f7d60d8961c23ca94ae82d82d89d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 1 Mar 2024 12:15:48 -0500 Subject: [PATCH] Rotates all versions of a note --- .../amethyst/model/LocalCache.kt | 32 +++- .../amethyst/ui/note/NoteCompose.kt | 150 +++++++++++++++--- .../amethyst/ui/screen/ThreadFeedView.kt | 24 ++- .../ui/screen/loggedIn/AccountViewModel.kt | 2 + app/src/main/res/values/strings.xml | 1 + .../events/TextNoteModificationEvent.kt | 2 + 6 files changed, 167 insertions(+), 44 deletions(-) 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 5e4d860e5..fc50dc25e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import android.util.Log +import android.util.LruCache import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -1402,6 +1403,13 @@ object LocalCache { note.loadEvent(event, author, emptyList()) + event.editedNote()?.let { + getNoteIfExists(it)?.let { editedNote -> + modificationCache.remove(editedNote.idHex) + editedNote.liveSet?.innerModifications?.invalidateData() + } + } + refreshObservers(note) } @@ -1716,15 +1724,31 @@ object LocalCache { return minTime } + val modificationCache = LruCache>(20) + + fun cachedModificationEventsForNote(note: Note): List? { + return modificationCache[note.idHex] + } + suspend fun findLatestModificationForNote(note: Note): List { checkNotInMainThread() + + modificationCache[note.idHex]?.let { + return it + } + val time = TimeUtils.now() - return noteListCache.filter { item -> - val noteEvent = item.event + val newNotes = + noteListCache.filter { item -> + val noteEvent = item.event - noteEvent is TextNoteModificationEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) - } + noteEvent is TextNoteModificationEvent && noteEvent.isTaggedEvent(note.idHex) && !noteEvent.isExpirationBefore(time) + }.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) + + modificationCache.put(note.idHex, newNotes) + + return newNotes } fun cleanObservers() { 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 32b3b853f..fc9a92a09 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 @@ -61,6 +61,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -1088,10 +1089,58 @@ fun InnerNoteWithReactions( } @Stable -class EditState( - val showOriginal: MutableState = mutableStateOf(false), - val modificationsInOrder: MutableState> = mutableStateOf(emptyList()), -) +class EditState() { + private var modificationsList: List = persistentListOf() + private var modificationToShowIndex: Int = -1 + + val modificationToShow: MutableState = mutableStateOf(null) + val showingVersion: MutableState = mutableStateOf(0) + + fun hasModificationsToShow(): Boolean = modificationsList.isNotEmpty() + + fun isOriginal(): Boolean = modificationToShowIndex < 0 + + fun isLatest(): Boolean = modificationToShowIndex == modificationsList.lastIndex + + fun originalVersionId() = 0 + + fun lastVersionId() = modificationsList.size + + fun versionId() = modificationToShowIndex + 1 + + fun nextModification() { + if (modificationToShowIndex < 0) { + modificationToShowIndex = 0 + modificationToShow.value = modificationsList.getOrNull(0) + } else { + modificationToShowIndex++ + if (modificationToShowIndex >= modificationsList.size) { + modificationToShowIndex = -1 + modificationToShow.value = null + } else { + modificationToShow.value = modificationsList.getOrNull(modificationToShowIndex) + } + } + + showingVersion.value = versionId() + } + + fun updateModifications(newModifications: List) { + if (modificationsList != newModifications) { + modificationsList = newModifications + + if (newModifications.isEmpty()) { + modificationToShow.value = null + modificationToShowIndex = -1 + } else { + modificationToShowIndex = newModifications.lastIndex + modificationToShow.value = newModifications.last() + } + } + + showingVersion.value = versionId() + } +} @Composable private fun NoteBody( @@ -1105,14 +1154,7 @@ 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 - } - } - } + val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) FirstUserInfoRow( baseNote = baseNote, @@ -1168,7 +1210,7 @@ private fun RenderNoteRow( backgroundColor: MutableState, makeItShort: Boolean, canPreview: Boolean, - editState: EditState, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -1358,7 +1400,7 @@ fun RenderTextEvent( makeItShort: Boolean, canPreview: Boolean, backgroundColor: MutableState, - editState: EditState, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -1371,10 +1413,10 @@ fun RenderTextEvent( derivedStateOf { val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null } val newBody = - if (editState.showOriginal.value || editState.modificationsInOrder.value.isEmpty()) { - body + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value?.event?.content() ?: body } else { - editState.modificationsInOrder.value.firstOrNull()?.event?.content() ?: body + body } if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) { @@ -2822,7 +2864,7 @@ fun DisplayLocation( fun FirstUserInfoRow( baseNote: Note, showAuthorPicture: Boolean, - editState: EditState, + editState: State>, accountViewModel: AccountViewModel, nav: (String) -> Unit, ) { @@ -2857,8 +2899,10 @@ fun FirstUserInfoRow( DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } - if (!editState.modificationsInOrder.value.isEmpty()) { - DisplayEditStatus(editState.showOriginal) + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.let { + DisplayEditStatus(it) + } } TimeAgo(baseNote) @@ -2868,15 +2912,69 @@ fun FirstUserInfoRow( } @Composable -fun DisplayEditStatus(showOriginal: MutableState) { +fun observeEdits( + baseNote: Note, + accountViewModel: AccountViewModel, +): State> { + val editState = + remember(baseNote.idHex) { + val cached = accountViewModel.cachedModificationEventsForNote(baseNote) + mutableStateOf( + if (cached != null) { + if (cached.isEmpty()) { + GenericLoadable.Empty() + } else { + val state = EditState() + state.updateModifications(cached) + GenericLoadable.Loaded(state) + } + } else { + GenericLoadable.Loading() + }, + ) + } + + val updatedNote = baseNote.live().innerModifications.observeAsState() + + LaunchedEffect(key1 = updatedNote) { + updatedNote.value?.note?.let { + accountViewModel.findModificationEventsForNote(it) { newModifications -> + if (newModifications.isEmpty()) { + if (editState.value !is GenericLoadable.Empty) { + editState.value = GenericLoadable.Empty() + } + } else { + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.updateModifications(newModifications) + } else { + val state = EditState() + state.updateModifications(newModifications) + editState.value = GenericLoadable.Loaded(state) + } + } + } + } + } + + return editState +} + +@Composable +fun DisplayEditStatus(editState: EditState) { ClickableText( text = - if (showOriginal.value) { - buildAnnotatedString { append(stringResource(id = R.string.original)) } - } else { - buildAnnotatedString { append(stringResource(id = R.string.edited)) } + buildAnnotatedString { + if (editState.showingVersion.value == editState.originalVersionId()) { + append(stringResource(id = R.string.original)) + } else if (editState.showingVersion.value == editState.lastVersionId()) { + append(stringResource(id = R.string.edited)) + } else { + append(stringResource(id = R.string.edited_number, editState.versionId())) + } }, - onClick = { showOriginal.value = !showOriginal.value }, + onClick = { + editState.nextModification() + }, style = LocalTextStyle.current.copy( color = MaterialTheme.colorScheme.placeholderText, 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 f65619417..c54c31228 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,7 +58,6 @@ 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 @@ -86,6 +85,7 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.InlineCarrousel import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status @@ -132,6 +132,7 @@ import com.vitorpamplona.amethyst.ui.note.RenderPostApproval import com.vitorpamplona.amethyst.ui.note.RenderRepost import com.vitorpamplona.amethyst.ui.note.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.VideoDisplay +import com.vitorpamplona.amethyst.ui.note.observeEdits import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -377,20 +378,13 @@ 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() .padding(top = 10.dp), ) { + val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel) + Row( modifier = Modifier @@ -421,8 +415,10 @@ fun NoteMaster( DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav) } - if (!editState.modificationsInOrder.value.isEmpty()) { - DisplayEditStatus(editState.showOriginal) + if (editState.value is GenericLoadable.Loaded) { + (editState.value as? GenericLoadable.Loaded)?.loaded?.let { + DisplayEditStatus(it) + } } Text( @@ -850,9 +846,9 @@ private fun RenderWikiHeaderForThreadPreview() { val accountViewModel = mockAccountViewModel() val nav: (String) -> Unit = {} - val editState by + val editState = remember { - mutableStateOf(EditState()) + mutableStateOf(GenericLoadable.Empty()) } runBlocking { 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 f18c22e44..88ec1d47b 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,8 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View } } + fun cachedModificationEventsForNote(note: Note) = LocalCache.cachedModificationEventsForNote(note) + suspend fun findModificationEventsForNote( note: Note, onResult: (List) -> Unit, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f56c9324b..74e89ae60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -51,6 +51,7 @@ Boost boosted edited + edit #%1$s original Quote Fork diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt index 377dbc91d..79018b85a 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TextNoteModificationEvent.kt @@ -34,6 +34,8 @@ class TextNoteModificationEvent( content: String, sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun editedNote() = firstTaggedEvent() + companion object { const val KIND = 1010 const val ALT = "Content Change Event"