Support for Kind 1 Edits
This commit is contained in:
@@ -89,6 +89,7 @@ import com.vitorpamplona.quartz.events.Response
|
|||||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||||
import com.vitorpamplona.quartz.events.StatusEvent
|
import com.vitorpamplona.quartz.events.StatusEvent
|
||||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||||
import com.vitorpamplona.quartz.events.WrappedEvent
|
import com.vitorpamplona.quartz.events.WrappedEvent
|
||||||
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
import com.vitorpamplona.quartz.events.ZapSplitSetup
|
||||||
import com.vitorpamplona.quartz.signers.NostrSigner
|
import com.vitorpamplona.quartz.signers.NostrSigner
|
||||||
@@ -1405,6 +1406,25 @@ class Account(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendEdit(
|
||||||
|
message: String,
|
||||||
|
originalNote: Note,
|
||||||
|
relayList: List<Relay>? = 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(
|
fun sendPoll(
|
||||||
message: String,
|
message: String,
|
||||||
replyTo: List<Note>?,
|
replyTo: List<Note>?,
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ import com.vitorpamplona.quartz.events.RepostEvent
|
|||||||
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
import com.vitorpamplona.quartz.events.SealedGossipEvent
|
||||||
import com.vitorpamplona.quartz.events.StatusEvent
|
import com.vitorpamplona.quartz.events.StatusEvent
|
||||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||||
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
|
||||||
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
import com.vitorpamplona.quartz.events.VideoVerticalEvent
|
||||||
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
import com.vitorpamplona.quartz.events.WikiNoteEvent
|
||||||
@@ -1384,6 +1385,26 @@ object LocalCache {
|
|||||||
refreshObservers(note)
|
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(
|
fun consume(
|
||||||
event: HighlightEvent,
|
event: HighlightEvent,
|
||||||
relay: Relay?,
|
relay: Relay?,
|
||||||
@@ -1695,6 +1716,17 @@ object LocalCache {
|
|||||||
return minTime
|
return minTime
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findLatestModificationForNote(note: Note): List<Note> {
|
||||||
|
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() {
|
fun cleanObservers() {
|
||||||
noteListCache.forEach { it.clearLive() }
|
noteListCache.forEach { it.clearLive() }
|
||||||
|
|
||||||
@@ -2048,6 +2080,7 @@ object LocalCache {
|
|||||||
}
|
}
|
||||||
is StatusEvent -> consume(event, relay)
|
is StatusEvent -> consume(event, relay)
|
||||||
is TextNoteEvent -> consume(event, relay)
|
is TextNoteEvent -> consume(event, relay)
|
||||||
|
is TextNoteModificationEvent -> consume(event, relay)
|
||||||
is VideoHorizontalEvent -> consume(event, relay)
|
is VideoHorizontalEvent -> consume(event, relay)
|
||||||
is VideoVerticalEvent -> consume(event, relay)
|
is VideoVerticalEvent -> consume(event, relay)
|
||||||
is WikiNoteEvent -> consume(event, relay)
|
is WikiNoteEvent -> consume(event, relay)
|
||||||
|
|||||||
@@ -948,6 +948,7 @@ class NoteLiveSet(u: Note) {
|
|||||||
val innerRelays = NoteBundledRefresherLiveData(u)
|
val innerRelays = NoteBundledRefresherLiveData(u)
|
||||||
val innerZaps = NoteBundledRefresherLiveData(u)
|
val innerZaps = NoteBundledRefresherLiveData(u)
|
||||||
val innerOts = NoteBundledRefresherLiveData(u)
|
val innerOts = NoteBundledRefresherLiveData(u)
|
||||||
|
val innerModifications = NoteBundledRefresherLiveData(u)
|
||||||
|
|
||||||
val metadata = innerMetadata.map { it }
|
val metadata = innerMetadata.map { it }
|
||||||
val reactions = innerReactions.map { it }
|
val reactions = innerReactions.map { it }
|
||||||
@@ -1001,8 +1002,9 @@ class NoteLiveSet(u: Note) {
|
|||||||
hasReactions.hasObservers() ||
|
hasReactions.hasObservers() ||
|
||||||
replyCount.hasObservers() ||
|
replyCount.hasObservers() ||
|
||||||
reactionCount.hasObservers() ||
|
reactionCount.hasObservers() ||
|
||||||
boostCount.hasObservers()
|
boostCount.hasObservers() ||
|
||||||
innerOts.hasObservers() ||
|
innerOts.hasObservers() ||
|
||||||
|
innerModifications.hasObservers()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
@@ -1014,6 +1016,7 @@ class NoteLiveSet(u: Note) {
|
|||||||
innerRelays.destroy()
|
innerRelays.destroy()
|
||||||
innerZaps.destroy()
|
innerZaps.destroy()
|
||||||
innerOts.destroy()
|
innerOts.destroy()
|
||||||
|
innerModifications.destroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.events.ReactionEvent
|
|||||||
import com.vitorpamplona.quartz.events.ReportEvent
|
import com.vitorpamplona.quartz.events.ReportEvent
|
||||||
import com.vitorpamplona.quartz.events.RepostEvent
|
import com.vitorpamplona.quartz.events.RepostEvent
|
||||||
import com.vitorpamplona.quartz.events.TextNoteEvent
|
import com.vitorpamplona.quartz.events.TextNoteEvent
|
||||||
|
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
|
||||||
|
|
||||||
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||||
private var eventsToWatch = setOf<Note>()
|
private var eventsToWatch = setOf<Note>()
|
||||||
@@ -136,6 +137,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
|||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
PollNoteEvent.KIND,
|
PollNoteEvent.KIND,
|
||||||
OtsEvent.KIND,
|
OtsEvent.KIND,
|
||||||
|
TextNoteModificationEvent.KIND,
|
||||||
),
|
),
|
||||||
tags = mapOf("e" to it.map { it.idHex }),
|
tags = mapOf("e" to it.map { it.idHex }),
|
||||||
since = findMinimumEOSEs(it),
|
since = findMinimumEOSEs(it),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<FileHeaderEvent>>(emptyList())
|
||||||
|
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 imageUploadingError =
|
||||||
|
MutableSharedFlow<String?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||||
|
|
||||||
|
var userSuggestions by mutableStateOf<List<User>>(emptyList())
|
||||||
|
var userSuggestionAnchor: TextRange? = null
|
||||||
|
var userSuggestionsMainMessage: UserSuggestionAnchor? = null
|
||||||
|
|
||||||
|
// Images and Videos
|
||||||
|
var contentToAddUrl by mutableStateOf<Uri?>(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<Relay>? = null) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun innerSendPost(relayList: List<Relay>? = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,7 +167,7 @@ fun BadgeCompose(
|
|||||||
tint = MaterialTheme.colorScheme.placeholderText,
|
tint = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
|
|
||||||
NoteDropDownMenu(note, popupExpanded, accountViewModel)
|
NoteDropDownMenu(note, popupExpanded, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ fun MessageSetCompose(
|
|||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
|
|
||||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ fun MultiSetCompose(
|
|||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
|
|
||||||
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel)
|
NoteDropDownMenu(baseNote, popupExpanded, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider(
|
Divider(
|
||||||
|
|||||||
@@ -60,16 +60,16 @@ import androidx.compose.material3.lightColorScheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Alignment.Companion.Center
|
|
||||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.capitalize
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -785,7 +784,7 @@ fun LongCommunityHeader(
|
|||||||
)
|
)
|
||||||
Spacer(DoubleHorzSpacer)
|
Spacer(DoubleHorzSpacer)
|
||||||
NormalTimeAgo(baseNote = baseNote, Modifier.weight(1f))
|
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<Boolean> = mutableStateOf(false),
|
||||||
|
val modificationsInOrder: MutableState<List<Note>> = mutableStateOf(emptyList()),
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoteBody(
|
private fun NoteBody(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
@@ -1103,9 +1108,19 @@ private fun NoteBody(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit,
|
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(
|
FirstUserInfoRow(
|
||||||
baseNote = baseNote,
|
baseNote = baseNote,
|
||||||
showAuthorPicture = showAuthorPicture,
|
showAuthorPicture = showAuthorPicture,
|
||||||
|
editState = editState,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
@@ -1133,12 +1148,13 @@ private fun NoteBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
RenderNoteRow(
|
RenderNoteRow(
|
||||||
baseNote,
|
baseNote = baseNote,
|
||||||
backgroundColor,
|
backgroundColor = backgroundColor,
|
||||||
makeItShort,
|
makeItShort = makeItShort,
|
||||||
canPreview,
|
canPreview = canPreview,
|
||||||
accountViewModel,
|
editState = editState,
|
||||||
nav,
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
)
|
)
|
||||||
|
|
||||||
val noteEvent = baseNote.event
|
val noteEvent = baseNote.event
|
||||||
@@ -1155,6 +1171,7 @@ private fun RenderNoteRow(
|
|||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
makeItShort: Boolean,
|
makeItShort: Boolean,
|
||||||
canPreview: Boolean,
|
canPreview: Boolean,
|
||||||
|
editState: EditState,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit,
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -1287,6 +1304,7 @@ private fun RenderNoteRow(
|
|||||||
makeItShort,
|
makeItShort,
|
||||||
canPreview,
|
canPreview,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
editState,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
@@ -1343,19 +1361,29 @@ fun RenderTextEvent(
|
|||||||
makeItShort: Boolean,
|
makeItShort: Boolean,
|
||||||
canPreview: Boolean,
|
canPreview: Boolean,
|
||||||
backgroundColor: MutableState<Color>,
|
backgroundColor: MutableState<Color>,
|
||||||
|
editState: EditState,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit,
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
LoadDecryptedContent(note, accountViewModel) { body ->
|
LoadDecryptedContent(
|
||||||
|
note,
|
||||||
|
accountViewModel,
|
||||||
|
) { body ->
|
||||||
val eventContent by
|
val eventContent by
|
||||||
remember(note.event) {
|
remember(note.event) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
|
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)) {
|
if (!subject.isNullOrBlank() && !newBody.split("\n")[0].contains(subject)) {
|
||||||
"### $subject\n$body"
|
"### $subject\n$newBody"
|
||||||
} else {
|
} else {
|
||||||
body
|
newBody
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2789,6 +2817,7 @@ fun DisplayLocation(
|
|||||||
fun FirstUserInfoRow(
|
fun FirstUserInfoRow(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
showAuthorPicture: Boolean,
|
showAuthorPicture: Boolean,
|
||||||
|
editState: EditState,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: (String) -> Unit,
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -2823,12 +2852,37 @@ fun FirstUserInfoRow(
|
|||||||
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!editState.modificationsInOrder.value.isEmpty()) {
|
||||||
|
DisplayEditStatus(editState.showOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
TimeAgo(baseNote)
|
TimeAgo(baseNote)
|
||||||
|
|
||||||
MoreOptionsButton(baseNote, accountViewModel)
|
MoreOptionsButton(baseNote, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DisplayEditStatus(showOriginal: MutableState<Boolean>) {
|
||||||
|
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
|
@Composable
|
||||||
private fun BoostedMark() {
|
private fun BoostedMark() {
|
||||||
Text(
|
Text(
|
||||||
@@ -2844,6 +2898,7 @@ private fun BoostedMark() {
|
|||||||
fun MoreOptionsButton(
|
fun MoreOptionsButton(
|
||||||
baseNote: Note,
|
baseNote: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val popupExpanded = remember { mutableStateOf(false) }
|
val popupExpanded = remember { mutableStateOf(false) }
|
||||||
val enablePopup = remember { { popupExpanded.value = true } }
|
val enablePopup = remember { { popupExpanded.value = true } }
|
||||||
@@ -2858,6 +2913,7 @@ fun MoreOptionsButton(
|
|||||||
baseNote,
|
baseNote,
|
||||||
popupExpanded,
|
popupExpanded,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
|
nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import androidx.lifecycle.map
|
|||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
import com.vitorpamplona.amethyst.model.User
|
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.RobohashAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
@@ -455,6 +456,7 @@ fun NoteDropDownMenu(
|
|||||||
note: Note,
|
note: Note,
|
||||||
popupExpanded: MutableState<Boolean>,
|
popupExpanded: MutableState<Boolean>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -473,6 +475,20 @@ fun NoteDropDownMenu(
|
|||||||
|
|
||||||
val onDismiss = remember(popupExpanded) { { popupExpanded.value = false } }
|
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(
|
DropdownMenu(
|
||||||
expanded = popupExpanded.value,
|
expanded = popupExpanded.value,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -551,6 +567,14 @@ fun NoteDropDownMenu(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
|
if (state.isLoggedUser) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.edit_post)) },
|
||||||
|
onClick = {
|
||||||
|
wantsToEditPost.value = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(R.string.broadcast)) },
|
text = { Text(stringResource(R.string.broadcast)) },
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
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.BadgeDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||||
import com.vitorpamplona.amethyst.ui.note.CreateImageHeader
|
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.DisplayHighlight
|
||||||
import com.vitorpamplona.amethyst.ui.note.DisplayLocation
|
import com.vitorpamplona.amethyst.ui.note.DisplayLocation
|
||||||
import com.vitorpamplona.amethyst.ui.note.DisplayOts
|
import com.vitorpamplona.amethyst.ui.note.DisplayOts
|
||||||
import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList
|
import com.vitorpamplona.amethyst.ui.note.DisplayPeopleList
|
||||||
import com.vitorpamplona.amethyst.ui.note.DisplayRelaySet
|
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.FileHeaderDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
|
||||||
import com.vitorpamplona.amethyst.ui.note.HiddenNote
|
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.ReactionsRow
|
||||||
import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition
|
import com.vitorpamplona.amethyst.ui.note.RenderAppDefinition
|
||||||
import com.vitorpamplona.amethyst.ui.note.RenderEmojiPack
|
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.RenderGitPatchEvent
|
||||||
import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent
|
import com.vitorpamplona.amethyst.ui.note.RenderGitRepositoryEvent
|
||||||
import com.vitorpamplona.amethyst.ui.note.RenderPinListEvent
|
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.CommunityPostApprovalEvent
|
||||||
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
import com.vitorpamplona.quartz.events.EmojiPackEvent
|
||||||
import com.vitorpamplona.quartz.events.Event
|
import com.vitorpamplona.quartz.events.Event
|
||||||
|
import com.vitorpamplona.quartz.events.FhirResourceEvent
|
||||||
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
import com.vitorpamplona.quartz.events.FileHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
|
||||||
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
import com.vitorpamplona.quartz.events.GenericRepostEvent
|
||||||
@@ -372,6 +377,15 @@ fun NoteMaster(
|
|||||||
onClick = { showHiddenNote = true },
|
onClick = { showHiddenNote = true },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
val editState by
|
||||||
|
produceState(initialValue = EditState(), key1 = baseNote) {
|
||||||
|
accountViewModel.findModificationEventsForNote(baseNote) { newModifications ->
|
||||||
|
if (value.modificationsInOrder.value != newModifications) {
|
||||||
|
value.modificationsInOrder.value = newModifications
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -407,6 +421,10 @@ fun NoteMaster(
|
|||||||
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
DisplayFollowingHashtagsInPost(baseNote, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!editState.modificationsInOrder.value.isEmpty()) {
|
||||||
|
DisplayEditStatus(editState.showOriginal)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
timeAgo(note.createdAt(), context = context),
|
timeAgo(note.createdAt(), context = context),
|
||||||
color = MaterialTheme.colorScheme.placeholderText,
|
color = MaterialTheme.colorScheme.placeholderText,
|
||||||
@@ -424,7 +442,7 @@ fun NoteMaster(
|
|||||||
tint = MaterialTheme.colorScheme.placeholderText,
|
tint = MaterialTheme.colorScheme.placeholderText,
|
||||||
)
|
)
|
||||||
|
|
||||||
NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel)
|
NoteDropDownMenu(baseNote, moreActionsExpanded, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,6 +550,8 @@ fun NoteMaster(
|
|||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
} else if (noteEvent is FhirResourceEvent) {
|
||||||
|
RenderFhirResource(baseNote, accountViewModel, nav)
|
||||||
} else if (noteEvent is GitRepositoryEvent) {
|
} else if (noteEvent is GitRepositoryEvent) {
|
||||||
RenderGitRepositoryEvent(baseNote, accountViewModel, nav)
|
RenderGitRepositoryEvent(baseNote, accountViewModel, nav)
|
||||||
} else if (noteEvent is GitPatchEvent) {
|
} else if (noteEvent is GitPatchEvent) {
|
||||||
@@ -577,6 +597,7 @@ fun NoteMaster(
|
|||||||
false,
|
false,
|
||||||
canPreview,
|
canPreview,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
editState,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
@@ -829,6 +850,11 @@ private fun RenderWikiHeaderForThreadPreview() {
|
|||||||
val accountViewModel = mockAccountViewModel()
|
val accountViewModel = mockAccountViewModel()
|
||||||
val nav: (String) -> Unit = {}
|
val nav: (String) -> Unit = {}
|
||||||
|
|
||||||
|
val editState by
|
||||||
|
remember {
|
||||||
|
mutableStateOf(EditState())
|
||||||
|
}
|
||||||
|
|
||||||
runBlocking {
|
runBlocking {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
LocalCache.justConsume(event, null)
|
LocalCache.justConsume(event, null)
|
||||||
@@ -851,6 +877,7 @@ private fun RenderWikiHeaderForThreadPreview() {
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
editState,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
@@ -870,6 +897,7 @@ private fun RenderWikiHeaderForThreadPreview() {
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
editState,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -932,6 +932,15 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun findModificationEventsForNote(
|
||||||
|
note: Note,
|
||||||
|
onResult: (List<Note>) -> Unit,
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
onResult(LocalCache.findLatestModificationForNote(note))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
|
private suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
|
||||||
return LocalCache.checkGetOrCreateChannel(key)
|
return LocalCache.checkGetOrCreateChannel(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -865,7 +865,7 @@ fun LongChannelHeader(
|
|||||||
)
|
)
|
||||||
Spacer(DoubleHorzSpacer)
|
Spacer(DoubleHorzSpacer)
|
||||||
NormalTimeAgo(note, remember { Modifier.weight(1f) })
|
NormalTimeAgo(note, remember { Modifier.weight(1f) })
|
||||||
MoreOptionsButton(note, accountViewModel)
|
MoreOptionsButton(note, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,12 +363,12 @@ private fun RenderAuthorInformation(
|
|||||||
) {
|
) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
NoteUsernameDisplay(note, remember { Modifier.weight(1f) })
|
NoteUsernameDisplay(note, remember { Modifier.weight(1f) })
|
||||||
VideoUserOptionAction(note, accountViewModel)
|
VideoUserOptionAction(note, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
ObserveDisplayNip05Status(
|
ObserveDisplayNip05Status(
|
||||||
remember { note.author!! },
|
note.author!!,
|
||||||
remember { Modifier.weight(1f) },
|
Modifier.weight(1f),
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
@@ -387,6 +387,7 @@ private fun RenderAuthorInformation(
|
|||||||
private fun VideoUserOptionAction(
|
private fun VideoUserOptionAction(
|
||||||
note: Note,
|
note: Note,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val popupExpanded = remember { mutableStateOf(false) }
|
val popupExpanded = remember { mutableStateOf(false) }
|
||||||
val enablePopup = remember { { popupExpanded.value = true } }
|
val enablePopup = remember { { popupExpanded.value = true } }
|
||||||
@@ -406,6 +407,7 @@ private fun VideoUserOptionAction(
|
|||||||
note,
|
note,
|
||||||
popupExpanded,
|
popupExpanded,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
|
nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
<string name="view_count">View count</string>
|
<string name="view_count">View count</string>
|
||||||
<string name="boost">Boost</string>
|
<string name="boost">Boost</string>
|
||||||
<string name="boosted">boosted</string>
|
<string name="boosted">boosted</string>
|
||||||
|
<string name="edited">edited</string>
|
||||||
|
<string name="original">original</string>
|
||||||
<string name="quote">Quote</string>
|
<string name="quote">Quote</string>
|
||||||
<string name="fork">Fork</string>
|
<string name="fork">Fork</string>
|
||||||
<string name="new_amount_in_sats">New Amount in Sats</string>
|
<string name="new_amount_in_sats">New Amount in Sats</string>
|
||||||
@@ -790,4 +792,6 @@
|
|||||||
|
|
||||||
<string name="ots_info_title">Timestamp Proof</string>
|
<string name="ots_info_title">Timestamp Proof</string>
|
||||||
<string name="ots_info_description">There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time.</string>
|
<string name="ots_info_description">There\'s proof this post was signed sometime before %1$s. The proof was stamped in the Bitcoin blockchain at that date and time.</string>
|
||||||
|
|
||||||
|
<string name="edit_post">Edit Post</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class EventFactory {
|
|||||||
SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig)
|
SealedGossipEvent.KIND -> SealedGossipEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
|
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
TextNoteEvent.KIND -> TextNoteEvent(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)
|
VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
|
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig)
|
VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig)
|
||||||
|
|||||||
@@ -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<Array<String>>,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user