Merge pull request #749 from greenart7c3/main

save a draft while you are typing the post
This commit is contained in:
Vitor Pamplona 2024-03-26 15:24:59 -04:00 committed by GitHub
commit d3a0ae743a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1166 additions and 243 deletions

View File

@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
@ -845,8 +846,15 @@ class Account(
}
suspend fun delete(note: Note) {
if (note.isDraft()) {
note.event?.let {
val drafts = LocalCache.getDrafts(it.id())
return delete(drafts)
}
} else {
return delete(listOf(note))
}
}
suspend fun delete(notes: List<Note>) {
if (!isWriteable()) return
@ -897,6 +905,12 @@ class Account(
fun broadcast(note: Note) {
note.event?.let {
if (note.isDraft()) {
val drafts = LocalCache.getDrafts(it.id())
drafts.forEach { draftNote ->
broadcast(draftNote)
}
} else {
if (it is WrappedEvent && it.host != null) {
it.host?.let { hostEvent -> Client.send(hostEvent) }
} else {
@ -904,6 +918,7 @@ class Account(
}
}
}
}
suspend fun updateAttestations() {
Log.d("Pending Attestations", "Updating ${pendingAttestations.size} pending attestations")
@ -929,6 +944,7 @@ class Account(
fun timestamp(note: Note) {
if (!isWriteable()) return
if (note.isDraft()) return
val id = note.event?.id() ?: note.idHex
@ -1318,6 +1334,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1345,7 +1362,16 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
@ -1357,6 +1383,7 @@ class Account(
}
}
}
}
fun sendGitReply(
message: String,
@ -1373,6 +1400,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1396,7 +1424,16 @@ class Account(
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
@ -1414,6 +1451,7 @@ class Account(
}
}
}
}
fun sendPost(
message: String,
@ -1430,6 +1468,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1453,7 +1492,16 @@ class Account(
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
@ -1471,6 +1519,7 @@ class Account(
}
}
}
}
fun sendEdit(
message: String,
@ -1510,6 +1559,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1533,7 +1583,16 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
@ -1546,6 +1605,7 @@ class Account(
}
}
}
}
fun sendChannelMessage(
message: String,
@ -1557,6 +1617,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1574,11 +1635,21 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
fun sendLiveMessage(
message: String,
@ -1590,6 +1661,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1608,11 +1680,21 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
fun sendPrivateMessage(
message: String,
@ -1624,6 +1706,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
sendPrivateMessage(
message,
@ -1635,6 +1718,7 @@ class Account(
zapRaiserAmount,
geohash,
nip94attachments,
draftTag,
)
}
@ -1648,6 +1732,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1667,11 +1752,21 @@ class Account(
nip94attachments = nip94attachments,
signer = signer,
advertiseNip18 = false,
isDraft = draftTag != null,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.id())
}
} else {
Client.send(it)
LocalCache.consume(it, null)
}
}
}
fun sendNIP24PrivateMessage(
message: String,
@ -1684,6 +1779,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String? = null,
) {
if (!isWriteable()) return
@ -1701,11 +1797,21 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
draftTag = draftTag,
signer = signer,
) {
if (draftTag != null) {
DraftEvent.create(draftTag, it.msg, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
LocalCache.justConsume(it.msg, null)
LocalCache.addDraft(draftTag, draftEvent.id(), it.msg.id())
}
} else {
broadcastPrivately(it)
}
}
}
fun broadcastPrivately(signedEvents: NIP24Factory.Result) {
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
@ -1851,6 +1957,7 @@ class Account(
isPrivate: Boolean,
) {
if (!isWriteable()) return
if (note.isDraft()) return
if (note is AddressableNote) {
BookmarkListEvent.addReplaceable(
@ -2218,6 +2325,7 @@ class Account(
fun cachedDecryptContent(note: Note): String? {
val event = note.event
return if (event is PrivateDmEvent && isWriteable()) {
event.cachedContentFor(signer)
} else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) {

View File

@ -0,0 +1,23 @@
/**
* 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.model
data class Drafts(val mainId: String, val eventId: String)

View File

@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.events.CommunityListEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
@ -128,7 +129,7 @@ object LocalCache {
val users = LargeCache<HexKey, User>()
val notes = LargeCache<HexKey, Note>()
val addressables = LargeCache<String, AddressableNote>()
val drafts = ConcurrentHashMap<String, MutableList<Drafts>>()
val channels = LargeCache<HexKey, Channel>()
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
@ -141,6 +142,34 @@ object LocalCache {
return null
}
fun draftNotes(draftTag: String): List<Note> {
return drafts[draftTag]?.mapNotNull {
getNoteIfExists(it.mainId)
} ?: listOf()
}
fun getDrafts(eventId: String): List<Note> {
return drafts.filter {
it.value.any { it.eventId == eventId }
}.values.map {
it.mapNotNull {
checkGetOrCreateNote(it.mainId)
}
}.flatten()
}
fun addDraft(
key: String,
mainId: String,
draftId: String,
) {
val data = drafts[key] ?: mutableListOf()
if (data.none { it.mainId == mainId }) {
data.add(Drafts(mainId, draftId))
drafts[key] = data
}
}
fun getOrCreateUser(key: HexKey): User {
// checkNotInMainThread()
require(isValidHex(key = key)) { "$key is not a valid hex" }
@ -2013,6 +2042,13 @@ object LocalCache {
}
}
private fun consume(
event: DraftEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
}
fun justConsume(
event: Event,
relay: Relay?,
@ -2050,6 +2086,7 @@ object LocalCache {
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is DraftEvent -> consume(event, relay)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)

View File

@ -184,6 +184,13 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun isDraft(): Boolean {
event?.let {
return it.sig().isBlank()
}
return false
}
fun loadEvent(
event: Event,
author: User,

View File

@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@ -229,6 +230,16 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
)
}
fun createDraftsFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(DraftEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
),
)
fun createGiftWrapsToMeFilter() =
TypedFilter(
types = COMMON_FEED_TYPES,
@ -262,7 +273,28 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
when (event) {
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
LocalCache.justConsume(event, relay)
event.plainContent(account.signer) {
val tag =
event.tags().filter { it.size > 1 && it[0] == "d" }.map {
it[1]
}.firstOrNull()
LocalCache.justConsume(it, relay)
tag?.let { lTag ->
LocalCache.addDraft(lTag, event.id(), it.id())
}
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
@ -270,17 +302,20 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (event is SealedGossipEvent) {
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
}
override fun markAsSeenOnRelay(
eventId: String,
@ -328,6 +363,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
createDraftsFilter(),
)
.ifEmpty { null }
} else {

View File

@ -34,8 +34,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun NewPollOption(
@ -45,7 +48,12 @@ fun NewPollOption(
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = { pollViewModel.pollOptions.remove(optionIndex) },
onClick = {
pollViewModel.pollOptions.remove(optionIndex)
pollViewModel.viewModelScope.launch(Dispatchers.IO) {
pollViewModel.saveDraft()
}
},
) {
Icon(
imageVector = Icons.Default.Delete,
@ -57,7 +65,12 @@ fun NewPollOption(
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
onValueChange = {
pollViewModel.pollOptions[optionIndex] = it
pollViewModel.viewModelScope.launch(Dispatchers.IO) {
pollViewModel.saveDraft()
}
},
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),

View File

@ -119,6 +119,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@ -171,13 +172,18 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@Composable
fun NewPostView(
onClose: () -> Unit,
@ -185,6 +191,7 @@ fun NewPostView(
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
draft: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -200,9 +207,17 @@ fun NewPostView(
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
launch(Dispatchers.IO) {
postViewModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
postViewModel.sendPost(relayList = relayList, localDraft = postViewModel.draftTag)
}
}
launch(Dispatchers.IO) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
@ -582,6 +597,9 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
}
AddGeoHash(postViewModel) {
@ -827,7 +845,12 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.title,
onValueChange = { postViewModel.title = it },
onValueChange = {
postViewModel.title = it
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@ -870,6 +893,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
postViewModel.price = it
}
}
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
},
placeholder = {
Text(
@ -934,7 +960,12 @@ fun SellProduct(postViewModel: NewPostViewModel) {
TextSpinner(
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = { postViewModel.condition = conditionTypes[it].first },
onSelect = {
postViewModel.condition = conditionTypes[it].first
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
},
modifier =
Modifier
.weight(1f)
@ -998,7 +1029,12 @@ fun SellProduct(postViewModel: NewPostViewModel) {
categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second
?: "",
options = categoryOptions,
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
onSelect = {
postViewModel.category = TextFieldValue(categoryTypes[it].second)
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
},
modifier =
Modifier
.weight(1f)
@ -1033,7 +1069,12 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.locationText,
onValueChange = { postViewModel.locationText = it },
onValueChange = {
postViewModel.locationText = it
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(

View File

@ -69,10 +69,12 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import java.util.UUID
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@ -82,6 +84,7 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var draftTag: String = UUID.randomUUID().toString()
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@ -164,6 +167,8 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
val draftTextChanges = Channel<String>(Channel.CONFLATED)
fun lnAddress(): String? {
return account?.userProfile()?.info?.lnAddress()
}
@ -182,10 +187,14 @@ open class NewPostViewModel() : ViewModel() {
quote: Note?,
fork: Note?,
version: Note?,
draft: Note?,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
if (draft != null) {
loadFromDraft(draft, accountViewModel)
} else {
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
@ -297,12 +306,117 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = true
}
}
fun sendPost(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
private fun loadFromDraft(
draft: Note,
accountViewModel: AccountViewModel,
) {
Log.d("draft", draft.event!!.toJson())
draftTag = LocalCache.drafts.filter {
it.value.any { it.eventId == draft.event?.id() }
}.keys.firstOrNull() ?: draftTag
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
contentToAddUrl = null
val localfowardZapTo = draft.event?.tags()?.filter { it.size > 1 && it[0] == "zap" } ?: listOf()
forwardZapTo = Split()
localfowardZapTo.forEach {
val user = LocalCache.getOrCreateUser(it[1])
val value = it.last().toFloatOrNull() ?: 0f
forwardZapTo.addItem(user, value)
}
forwardZapToEditting = TextFieldValue("")
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
wantsToMarkAsSensitive = draft.event?.tags()?.any { it.size > 1 && it[0] == "content-warning" } ?: false
wantsToAddGeoHash = draft.event?.tags()?.any { it.size > 1 && it[0] == "g" } ?: false
val zapraiser = draft.event?.tags()?.filter { it.size > 1 && it[0] == "zapraiser" } ?: listOf()
wantsZapraiser = zapraiser.isNotEmpty()
zapRaiserAmount = null
if (wantsZapraiser) {
zapRaiserAmount = zapraiser.first()[1].toLongOrNull() ?: 0
}
eTags =
draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }?.mapNotNull {
val note = LocalCache.checkGetOrCreateNote(it[1])
note
}
pTags =
draft.event?.tags()?.filter { it.size > 1 && it[0] == "p" }?.map {
LocalCache.getOrCreateUser(it[1])
}
draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "fork" }?.forEach {
val note = LocalCache.checkGetOrCreateNote(it[1])
forkedFromNote = note
}
originalNote =
draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "root" }?.map {
LocalCache.checkGetOrCreateNote(it[1])
}?.firstOrNull()
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
if (forwardZapTo.items.isNotEmpty()) {
wantsForwardZapTo = true
}
val polls = draft.event?.tags()?.filter { it.size > 1 && it[0] == "poll_option" } ?: emptyList()
wantsPoll = polls.isNotEmpty()
polls.forEach {
pollOptions[it[1].toInt()] = it[2]
}
val minMax = draft.event?.tags()?.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } ?: listOf()
minMax.forEach {
if (it[0] == "value_maximum") {
valueMaximum = it[1].toInt()
} else if (it[0] == "value_minimum") {
valueMinimum = it[1].toInt()
}
}
wantsProduct = draft.event?.kind() == 30402
title = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "title" }?.map { it[1] }?.firstOrNull() ?: "")
price = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "price" }?.map { it[1] }?.firstOrNull() ?: "")
category = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "t" }?.map { it[1] }?.firstOrNull() ?: "")
locationText = TextFieldValue(draft.event?.tags()?.filter { it.size > 1 && it[0] == "location" }?.map { it[1] }?.firstOrNull() ?: "")
condition = ClassifiedsEvent.CONDITION.entries.firstOrNull {
it.value == draft.event?.tags()?.filter { it.size > 1 && it[0] == "condition" }?.map { it[1] }?.firstOrNull()
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
message =
if (draft.event is PrivateDmEvent) {
val event = draft.event as PrivateDmEvent
TextFieldValue(event.cachedContentFor(accountViewModel.account.signer) ?: "")
} else {
TextFieldValue(draft.event?.content() ?: "")
}
nip24 = draft.event is ChatMessageEvent
urlPreview = findUrlInMessage()
}
fun sendPost(
relayList: List<Relay>? = null,
localDraft: String? = null,
) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList, localDraft) }
}
private suspend fun innerSendPost(
relayList: List<Relay>? = null,
localDraft: String?,
) {
if (accountViewModel == null) {
cancel()
return
@ -363,6 +477,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendChannelMessage(
@ -375,6 +490,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is PrivateDmEvent) {
@ -388,6 +504,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is ChatMessageEvent) {
val receivers =
@ -423,6 +540,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendPrivateMessage(
@ -435,6 +553,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is GitIssueEvent) {
@ -475,6 +594,7 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
if (wantsPoll) {
@ -493,6 +613,7 @@ open class NewPostViewModel() : ViewModel() {
relayList,
geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (wantsProduct) {
account?.sendClassifieds(
@ -511,6 +632,7 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
// adds markers
@ -547,12 +669,14 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
}
if (localDraft == null) {
cancel()
}
}
fun upload(
galleryUri: Uri,
@ -635,6 +759,7 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = null
isUploadingImage = false
pTags = null
eTags = null
wantsDirectMessage = false
@ -663,6 +788,11 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
userSuggestionAnchor = null
userSuggestionsMainMessage = null
originalNote = null
viewModelScope.launch(Dispatchers.IO) {
accountViewModel?.deleteDraft(draftTag)
}
NostrSearchEventOrUserDataSource.clear()
}
@ -679,6 +809,10 @@ open class NewPostViewModel() : ViewModel() {
pTags = pTags?.filter { it != userToRemove }
}
open suspend fun saveDraft() {
draftTextChanges.send("")
}
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@ -701,6 +835,10 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
open fun updateToUsers(it: TextFieldValue) {
@ -724,10 +862,16 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
open fun updateSubject(it: TextFieldValue) {
subject = it
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
open fun updateZapForwardTo(it: TextFieldValue) {
@ -754,6 +898,9 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
open fun autocompleteWithUser(item: User) {
@ -799,6 +946,10 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
@ -869,6 +1020,9 @@ open class NewPostViewModel() : ViewModel() {
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
},
onError = {
@ -913,6 +1067,9 @@ open class NewPostViewModel() : ViewModel() {
}
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
},
onError = {
@ -933,6 +1090,7 @@ open class NewPostViewModel() : ViewModel() {
locUtil?.let {
location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
viewModelScope.launch(Dispatchers.IO) { saveDraft() }
}
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
@ -957,6 +1115,11 @@ open class NewPostViewModel() : ViewModel() {
} else {
nip24 = !nip24
}
if (message.text.isNotBlank()) {
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
}
fun updateMinZapAmountForPoll(textMin: String) {
@ -976,6 +1139,9 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
fun updateMaxZapAmountForPoll(textMax: String) {
@ -995,6 +1161,9 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
}
fun checkMinMax() {

View File

@ -39,6 +39,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Lightning
@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ZapRaiserRequest(
@ -98,6 +101,9 @@ fun ZapRaiserRequest(
} else {
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
}
newPostViewModel.viewModelScope.launch(Dispatchers.IO) {
newPostViewModel.saveDraft()
}
}
},
placeholder = {

View File

@ -88,6 +88,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.service.relays.RelayPoolStatus
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.ClickableText
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@ -159,7 +160,10 @@ fun DrawerContent(
)
ListContent(
modifier = Modifier.fillMaxWidth().weight(1f),
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
drawerState,
openSheet,
accountViewModel,
@ -231,7 +235,8 @@ fun ProfileContentTemplate(
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier =
Modifier.width(100.dp)
Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
@ -244,7 +249,10 @@ fun ProfileContentTemplate(
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick),
modifier =
Modifier
.padding(top = 7.dp)
.clickable(onClick = onClick),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
maxLines = 1,
@ -454,8 +462,17 @@ fun ListContent(
val proxyPort = remember { mutableStateOf(accountViewModel.account.proxyPort.toString()) }
val context = LocalContext.current
var draftText by remember {
mutableStateOf<String?>(null)
}
var wantsToPost by remember { mutableStateOf(false) }
Column(
modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
modifier =
modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
NavigationRow(
title = stringResource(R.string.profile),
@ -571,6 +588,18 @@ fun ListContent(
)
}
if (wantsToPost) {
NewPostView(
{
wantsToPost = false
draftText = null
coroutineScope.launch { drawerState.close() }
},
accountViewModel = accountViewModel,
nav = nav,
)
}
if (disconnectTorDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title)) },
@ -662,7 +691,8 @@ fun IconRow(
) {
Row(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
@ -693,10 +723,16 @@ fun IconRowRelays(
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
modifier =
Modifier
.fillMaxWidth()
.clickable { onClick() },
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@ -737,7 +773,10 @@ fun BottomContent(
thickness = DividerThickness,
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableText(

View File

@ -147,7 +147,7 @@ fun NormalChannelCard(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) { showPopup ->
CheckNewAndRenderChannelCard(
baseNote,
routeForLastRead,

View File

@ -64,6 +64,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
@ -102,6 +103,7 @@ fun ChatroomMessageCompose(
innerQuote: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
) {
@ -120,6 +122,7 @@ fun ChatroomMessageCompose(
canPreview,
parentBackgroundColor,
accountViewModel,
newPostViewModel,
nav,
onWantsToReply,
)
@ -136,6 +139,7 @@ fun NormalChatNote(
canPreview: Boolean = true,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
) {
@ -255,6 +259,7 @@ fun NormalChatNote(
availableBubbleSize,
showDetails,
accountViewModel,
newPostViewModel,
nav,
)
}
@ -265,6 +270,7 @@ fun NormalChatNote(
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
)
}
}
@ -282,6 +288,7 @@ private fun RenderBubble(
availableBubbleSize: MutableState<Int>,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
) {
val bubbleSize = remember { mutableIntStateOf(0) }
@ -311,6 +318,7 @@ private fun RenderBubble(
canPreview,
showDetails,
accountViewModel,
newPostViewModel,
nav,
)
}
@ -329,6 +337,7 @@ private fun MessageBubbleLines(
canPreview: Boolean,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
) {
if (drawAuthorInfo) {
@ -345,6 +354,7 @@ private fun MessageBubbleLines(
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
)
@ -363,6 +373,9 @@ private fun MessageBubbleLines(
bubbleSize = bubbleSize,
availableBubbleSize = availableBubbleSize,
firstColumn = {
if (baseNote.isDraft()) {
DisplayDraftChat()
}
IncognitoBadge(baseNote)
ChatTimeAgo(baseNote)
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
@ -394,11 +407,12 @@ private fun RenderReplyRow(
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
) {
if (!innerQuote && note.replyTo?.lastOrNull() != null) {
RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply)
RenderReply(note, backgroundBubbleColor, accountViewModel, newPostViewModel, nav, onWantsToReply)
}
}
@ -407,6 +421,7 @@ private fun RenderReply(
note: Note,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
) {
@ -425,6 +440,7 @@ private fun RenderReply(
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
)

View File

@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@ -48,6 +49,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
@ -105,8 +107,11 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
@ -122,6 +127,7 @@ import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyBackground
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.events.AppDefinitionEvent
@ -238,7 +244,7 @@ fun AcceptableNote(
nav = nav,
)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) {
showPopup,
->
CheckNewAndRenderNote(
@ -273,7 +279,7 @@ fun AcceptableNote(
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) {
showPopup,
->
CheckNewAndRenderNote(
@ -868,6 +874,29 @@ fun DisplayOtsIfInOriginal(
}
}
@Composable
fun DisplayDraft() {
Text(
"Draft",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
modifier = HalfStartPadding,
)
}
@Composable
fun DisplayDraftChat() {
Text(
"Draft",
color = MaterialTheme.colorScheme.placeholderText,
modifier = HalfEndPadding,
fontWeight = FontWeight.Bold,
fontSize = Font12SP,
maxLines = 1,
)
}
@Composable
fun FirstUserInfoRow(
baseNote: Note,
@ -910,6 +939,10 @@ fun FirstUserInfoRow(
}
}
if (baseNote.isDraft()) {
DisplayDraft()
}
TimeAgo(baseNote)
MoreOptionsButton(baseNote, editState, accountViewModel, nav)

View File

@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
@ -84,6 +85,8 @@ import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
@ -132,6 +135,7 @@ val externalLinkForNote = { note: Note ->
fun LongPressToQuickAction(
baseNote: Note,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
content: @Composable (() -> Unit) -> Unit,
) {
val popupExpanded = remember { mutableStateOf(false) }
@ -140,7 +144,7 @@ fun LongPressToQuickAction(
content(showPopup)
NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel)
NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel, newPostViewModel)
}
@Composable
@ -149,20 +153,24 @@ fun NoteQuickActionMenu(
popupExpanded: Boolean,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
) {
val showSelectTextDialog = remember { mutableStateOf(false) }
val showDeleteAlertDialog = remember { mutableStateOf(false) }
val showBlockAlertDialog = remember { mutableStateOf(false) }
val showReportDialog = remember { mutableStateOf(false) }
val editDraftDialog = remember { mutableStateOf(false) }
if (popupExpanded) {
RenderMainPopup(
accountViewModel,
newPostViewModel,
note,
onDismiss,
showBlockAlertDialog,
showDeleteAlertDialog,
showReportDialog,
editDraftDialog,
)
}
@ -199,16 +207,29 @@ fun NoteQuickActionMenu(
onDismiss()
}
}
if (editDraftDialog.value) {
NewPostView(
onClose = {
editDraftDialog.value = false
},
accountViewModel = accountViewModel,
draft = note,
nav = { },
)
}
}
@Composable
private fun RenderMainPopup(
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
note: Note,
onDismiss: () -> Unit,
showBlockAlertDialog: MutableState<Boolean>,
showDeleteAlertDialog: MutableState<Boolean>,
showReportDialog: MutableState<Boolean>,
editDraftDialog: MutableState<Boolean>,
) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f)
@ -279,6 +300,22 @@ private fun RenderMainPopup(
}
}
if (note.isDraft()) {
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
Icons.Default.Edit,
stringResource(R.string.edit_draft),
) {
if (newPostViewModel != null) {
newPostViewModel.load(accountViewModel, null, null, null, null, note)
onDismiss()
} else {
editDraftDialog.value = true
onDismiss()
}
}
}
if (!isOwnNote) {
VerticalDivider(color = primaryLight)
@ -389,14 +426,20 @@ fun NoteQuickActionItem(
onClick: () -> Unit,
) {
Column(
modifier = Modifier.size(70.dp).clickable { onClick() },
modifier =
Modifier
.size(70.dp)
.clickable { onClick() },
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp).padding(bottom = 5.dp),
modifier =
Modifier
.size(24.dp)
.padding(bottom = 5.dp),
tint = Color.White,
)
Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center)
@ -527,7 +570,10 @@ fun QuickActionAlertDialog(
text = { Text(textContent) },
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
modifier =
Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = onClickDontShowAgain) {

View File

@ -310,7 +310,12 @@ fun RenderZapRaiser(
}
LinearProgressIndicator(
modifier = remember(details) { Modifier.fillMaxWidth().height(if (details) 24.dp else 4.dp) },
modifier =
remember(details) {
Modifier
.fillMaxWidth()
.height(if (details) 24.dp else 4.dp)
},
color = color,
progress = { zapraiserStatus.progress },
)
@ -590,6 +595,13 @@ fun ReplyReaction(
IconButton(
modifier = iconSizeModifier,
onClick = {
if (baseNote.isDraft()) {
accountViewModel.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_reply_to_a_draft_note,
)
return@IconButton
}
if (accountViewModel.isWriteable()) {
onPress()
} else {
@ -776,7 +788,8 @@ fun LikeReaction(
Box(
contentAlignment = Center,
modifier =
Modifier.size(iconSize)
Modifier
.size(iconSize)
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
@ -784,6 +797,7 @@ fun LikeReaction(
onClick = {
likeClick(
accountViewModel,
baseNote,
onMultipleChoices = { wantsToReact = true },
onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) },
)
@ -886,9 +900,17 @@ fun ObserveLikeText(
private fun likeClick(
accountViewModel: AccountViewModel,
baseNote: Note,
onMultipleChoices: () -> Unit,
onWantsToSignReaction: () -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_react_to_a_draft_note,
)
return
}
if (accountViewModel.account.reactionChoices.isEmpty()) {
accountViewModel.toast(
R.string.no_reactions_setup,
@ -1082,6 +1104,14 @@ fun zapClick(
onError: (String, String) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
if (baseNote.isDraft()) {
accountViewModel.toast(
R.string.draft_note,
R.string.it_s_not_possible_to_zap_to_a_draft_note,
)
return
}
if (accountViewModel.account.zapAmountChoices.isEmpty()) {
accountViewModel.toast(
context.getString(R.string.error_dialog_zap_error),

View File

@ -52,6 +52,7 @@ fun WatchNoteEvent(
LongPressToQuickAction(
baseNote = baseNote,
accountViewModel = accountViewModel,
newPostViewModel = null,
) { showPopup ->
BlankNote(
remember {

View File

@ -179,7 +179,7 @@ fun ZapCustomDialog(
)
ZapButton(
isActive = postViewModel.canSend(),
isActive = postViewModel.canSend() && !baseNote.isDraft(),
) {
accountViewModel.zap(
baseNote,

View File

@ -185,6 +185,7 @@ class AddBountyAmountViewModel : ViewModel() {
root = null,
directMentions = setOf(),
forkedFrom = null,
draftTag = null,
)
nextAmount = TextFieldValue("")

View File

@ -45,6 +45,7 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.EditPostView
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
@ -122,6 +123,11 @@ fun NoteDropDownMenu(
mutableStateOf(false)
}
val wantsToEditDraft =
remember {
mutableStateOf(false)
}
if (wantsToEditPost.value) {
// avoids changing while drafting a note and a new event shows up.
val versionLookingAt =
@ -141,6 +147,18 @@ fun NoteDropDownMenu(
)
}
if (wantsToEditDraft.value) {
NewPostView(
onClose = {
popupExpanded.value = false
wantsToEditDraft.value = false
},
accountViewModel = accountViewModel,
draft = note,
nav = nav,
)
}
DropdownMenu(
expanded = popupExpanded.value,
onDismissRequest = onDismiss,
@ -219,7 +237,15 @@ fun NoteDropDownMenu(
},
)
HorizontalDivider(thickness = DividerThickness)
if (note.event is TextNoteEvent) {
if (note.isDraft()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_draft)) },
onClick = {
wantsToEditDraft.value = true
},
)
}
if (note.event is TextNoteEvent && !note.isDraft()) {
if (state.isLoggedUser) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_post)) },

View File

@ -38,6 +38,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel
import com.vitorpamplona.amethyst.ui.note.ChatroomMessageCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.theme.HalfPadding
fun RefreshingChatroomFeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
@ -59,6 +61,7 @@ fun RefreshingChatroomFeedView(
RenderChatroomFeedView(
viewModel,
accountViewModel,
newPostViewModel,
listState,
nav,
routeForLastRead,
@ -72,6 +75,7 @@ fun RefreshingChatroomFeedView(
fun RenderChatroomFeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String,
@ -91,6 +95,7 @@ fun RenderChatroomFeedView(
ChatroomFeedLoaded(
state,
accountViewModel,
newPostViewModel,
listState,
nav,
routeForLastRead,
@ -108,6 +113,7 @@ fun RenderChatroomFeedView(
fun ChatroomFeedLoaded(
state: FeedState.Loaded,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String,
@ -130,6 +136,7 @@ fun ChatroomFeedLoaded(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
)

View File

@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DisplayDraft
import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@ -446,6 +447,10 @@ fun NoteMaster(
DisplayPoW(pow)
}
if (note.isDraft()) {
DisplayDraft()
}
DisplayOtsIfInOriginal(note, editState, accountViewModel)
}
}
@ -605,7 +610,7 @@ fun NoteMaster(
ReactionsRow(note, true, editState, accountViewModel, nav)
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel, null)
}
}

View File

@ -1209,6 +1209,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
baseNote: Note,
onMore: () -> Unit,
) {
if (baseNote.isDraft()) {
toast(
R.string.draft_note,
R.string.it_s_not_possible_to_quote_to_a_draft_note,
)
return
}
if (isWriteable()) {
if (hasBoosted(baseNote)) {
deleteBoostsTo(baseNote)
@ -1304,6 +1312,11 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
}
suspend fun deleteDraft(draftTag: String) {
val notes = LocalCache.draftNotes(draftTag)
account.delete(notes)
}
val bechLinkCache = CachedLoadedBechLink(this)
class CachedLoadedBechLink(val accountViewModel: AccountViewModel) : GenericBaseCache<String, LoadedBechLink>(20) {

View File

@ -163,12 +163,17 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
@Composable
fun ChannelScreen(
@ -187,6 +192,7 @@ fun ChannelScreen(
}
}
@OptIn(FlowPreview::class)
@Composable
fun PrepareChannelViewModels(
baseChannel: Channel,
@ -204,8 +210,21 @@ fun PrepareChannelViewModels(
)
val channelScreenModel: NewPostViewModel = viewModel()
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
channelScreenModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
channelScreenModel.sendPost(localDraft = channelScreenModel.draftTag)
}
}
}
channelScreenModel.accountViewModel = accountViewModel
channelScreenModel.account = accountViewModel.account
channelScreenModel.originalNote = LocalCache.getNoteIfExists(baseChannel.idHex)
ChannelScreen(
channel = baseChannel,
@ -287,6 +306,7 @@ fun ChannelScreen(
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
newPostViewModel = newPostModel,
nav = nav,
routeForLastRead = "Channel/${channel.idHex}",
onWantsToReply = { replyTo.value = it },
@ -295,7 +315,7 @@ fun ChannelScreen(
Spacer(modifier = DoubleVertSpacer)
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } }
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } }
val scope = rememberCoroutineScope()
@ -323,6 +343,7 @@ fun ChannelScreen(
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
@ -332,10 +353,13 @@ fun ChannelScreen(
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
}
newPostModel.message = TextFieldValue("")
replyTo.value = null
accountViewModel.deleteDraft(newPostModel.draftTag)
newPostModel.draftTag = UUID.randomUUID().toString()
feedViewModel.sendToTop()
}
}
@ -346,6 +370,7 @@ fun ChannelScreen(
fun DisplayReplyingToNote(
replyingNote: Note?,
accountViewModel: AccountViewModel,
newPostModel: NewPostViewModel,
nav: (String) -> Unit,
onCancel: () -> Unit,
) {
@ -364,6 +389,7 @@ fun DisplayReplyingToNote(
null,
innerQuote = true,
accountViewModel = accountViewModel,
newPostViewModel = newPostModel,
nav = nav,
onWantsToReply = {},
)
@ -665,7 +691,12 @@ fun ShowVideoStreaming(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = remember { Modifier.fillMaxWidth().heightIn(min = 50.dp, max = 300.dp) },
modifier =
remember {
Modifier
.fillMaxWidth()
.heightIn(min = 50.dp, max = 300.dp)
},
) {
val zoomableUrlVideo =
remember(streamingInfo) {

View File

@ -116,14 +116,21 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.findURLs
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
@Composable
fun ChatroomScreen(
@ -206,6 +213,7 @@ fun LoadRoomByAuthor(
content(room)
}
@OptIn(FlowPreview::class)
@Composable
fun PrepareChatroomViewModels(
room: ChatroomKey,
@ -230,8 +238,20 @@ fun PrepareChatroomViewModels(
if (newPostModel.requiresNIP24) {
newPostModel.nip24 = true
}
room.users.forEach {
newPostModel.toUsers = TextFieldValue(newPostModel.toUsers.text + " @${Hex.decode(it).toNpub()}")
}
LaunchedEffect(key1 = newPostModel) {
launch(Dispatchers.IO) {
newPostModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
newPostModel.sendPost(localDraft = newPostModel.draftTag)
}
}
launch(Dispatchers.IO) {
val hasNIP24 =
accountViewModel.userProfile().privateChatrooms[room]?.roomMessages?.any {
@ -313,21 +333,28 @@ fun ChatroomScreen(
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
newPostViewModel = newPostModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
onWantsToReply = { replyTo.value = it },
onWantsToReply = {
replyTo.value = it
newPostModel.originalNote = it
},
)
}
Spacer(modifier = Modifier.height(10.dp))
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } }
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } }
val scope = rememberCoroutineScope()
// LAST ROW
PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
scope.launch(Dispatchers.IO) {
accountViewModel.deleteDraft(newPostModel.draftTag)
newPostModel.draftTag = UUID.randomUUID().toString()
val urls = findURLs(newPostModel.message.text)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
@ -339,6 +366,7 @@ fun ChatroomScreen(
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
} else {
accountViewModel.account.sendPrivateMessage(
@ -348,6 +376,7 @@ fun ChatroomScreen(
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
}

View File

@ -93,6 +93,7 @@ val Size40dp = 40.dp
val Size55dp = 55.dp
val Size75dp = 75.dp
val HalfEndPadding = Modifier.padding(end = 5.dp)
val HalfStartPadding = Modifier.padding(start = 5.dp)
val StdStartPadding = Modifier.padding(start = 10.dp)
val StdTopPadding = Modifier.padding(top = 10.dp)

View File

@ -733,6 +733,8 @@
<string name="could_not_download_from_the_server">Could not download uploaded media from the server</string>
<string name="could_not_prepare_local_file_to_upload">Could not prepare local file to upload: %1$s</string>
<string name="edit_draft">Edit draft</string>
<string name="login_with_qr_code">Login with QR Code</string>
<string name="route">Route</string>
<string name="route_home">Home</string>
@ -819,4 +821,9 @@
<string name="accessibility_play_username">Play username as audio</string>
<string name="accessibility_scan_qr_code">Scan QR code</string>
<string name="accessibility_navigate_to_alby">Navigate to the third-party wallet provider Alby</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">It\'s not possible to reply a draft note</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">It\'s not possible to quote a draft note</string>
<string name="it_s_not_possible_to_react_to_a_draft_note">It\'s not possible to react a draft note</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">It\'s not possible to zap a draft note</string>
<string name="draft_note">Draft Note</string>
</resources>

View File

@ -60,6 +60,7 @@ class ChannelMessageEvent(
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
isDraft: Boolean,
onReady: (ChannelMessageEvent) -> Unit,
) {
val tags =
@ -87,7 +88,7 @@ class ChannelMessageEvent(
arrayOf("alt", ALT),
)
signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady, isDraft)
}
}
}

View File

@ -82,6 +82,7 @@ class ChatMessageEvent(
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
nip94attachments: List<FileHeaderEvent>? = null,
isDraft: Boolean,
onReady: (ChatMessageEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
@ -106,7 +107,7 @@ class ChatMessageEvent(
}
// tags.add(arrayOf("alt", alt))
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft)
}
}
}

View File

@ -113,6 +113,7 @@ class ClassifiedsEvent(
nip94attachments: List<Event>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
onReady: (ClassifiedsEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
@ -192,7 +193,7 @@ class ClassifiedsEvent(
}
tags.add(arrayOf("alt", ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), message, onReady, isDraft)
}
}
}

View File

@ -0,0 +1,161 @@
/**
* 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.encoders.Nip19Bech32
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class DraftEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var decryptedContent: Map<HexKey, Event> = mapOf()
@Transient private var citedNotesCache: Set<String>? = null
fun replyTos(): List<HexKey> {
val oldStylePositional = tags.filter { it.size > 1 && it.size <= 3 && it[0] == "e" }.map { it[1] }
val newStyleReply = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "reply" }?.get(1)
val newStyleRoot = tags.lastOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" }?.get(1)
val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot)
return if (newStyleReplyTos.isNotEmpty()) {
newStyleReplyTos
} else {
oldStylePositional
}
}
fun findCitations(): Set<HexKey> {
citedNotesCache?.let {
return it
}
val citations = mutableSetOf<HexKey>()
// Removes citations from replies:
val matcher = tagSearch.matcher(content)
while (matcher.find()) {
try {
val tag = matcher.group(1)?.let { tags[it.toInt()] }
if (tag != null && tag.size > 1 && tag[0] == "e") {
citations.add(tag[1])
}
if (tag != null && tag.size > 1 && tag[0] == "a") {
citations.add(tag[1])
}
} catch (e: Exception) {
}
}
val matcher2 = Nip19Bech32.nip19regex.matcher(content)
while (matcher2.find()) {
val type = matcher2.group(2) // npub1
val key = matcher2.group(3) // bech32
val additionalChars = matcher2.group(4) // additional chars
if (type != null) {
val parsed = Nip19Bech32.parseComponents(type, key, additionalChars)?.entity
if (parsed != null) {
when (parsed) {
is Nip19Bech32.NEvent -> citations.add(parsed.hex)
is Nip19Bech32.NAddress -> citations.add(parsed.atag)
is Nip19Bech32.Note -> citations.add(parsed.hex)
is Nip19Bech32.NEmbed -> citations.add(parsed.event.id)
}
}
}
}
citedNotesCache = citations
return citations
}
fun tagsWithoutCitations(): List<String> {
val repliesTo = replyTos()
val tagAddresses =
taggedAddresses().filter {
it.kind != CommunityDefinitionEvent.KIND &&
it.kind != WikiNoteEvent.KIND
}.map { it.toTag() }
if (repliesTo.isEmpty() && tagAddresses.isEmpty()) return emptyList()
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
}
fun cachedContentFor(): Event? {
return decryptedContent[dTag()]
}
fun plainContent(
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
decryptedContent[dTag()]?.let {
onReady(it)
return
}
signer.nip44Decrypt(content, signer.pubKey) { retVal ->
val event = runCatching { fromJson(retVal) }.getOrNull() ?: return@nip44Decrypt
decryptedContent = decryptedContent + Pair(dTag(), event)
onReady(event)
}
}
companion object {
const val KIND = 31234
fun create(
dTag: String,
originalNote: EventInterface,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
tags.add(arrayOf("d", dTag))
tags.add(arrayOf("k", "${originalNote.kind()}"))
tags.addAll(originalNote.tags().filter { it.size > 1 && it[0] == "e" })
tags.addAll(originalNote.tags().filter { it.size > 1 && it[0] == "a" })
signer.nip44Encrypt(originalNote.toJson(), signer.pubKey) { encryptedContent ->
signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent, onReady)
}
}
}
}

View File

@ -79,6 +79,7 @@ class EventFactory {
CommunityPostApprovalEvent(id, pubKey, createdAt, tags, content, sig)
ContactListEvent.KIND -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.KIND -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
DraftEvent.KIND -> DraftEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackEvent.KIND -> EmojiPackEvent(id, pubKey, createdAt, tags, content, sig)
EmojiPackSelectionEvent.KIND ->
EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)

View File

@ -94,6 +94,7 @@ class GitReplyEvent(
forkedFrom: Event? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
onReady: (GitReplyEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
@ -156,7 +157,7 @@ class GitReplyEvent(
}
tags.add(arrayOf("alt", "a git issue reply"))
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft)
}
}
}

View File

@ -72,6 +72,7 @@ class LiveActivitiesChatMessageEvent(
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
isDraft: Boolean,
onReady: (LiveActivitiesChatMessageEvent) -> Unit,
) {
val content = message
@ -98,7 +99,7 @@ class LiveActivitiesChatMessageEvent(
}
tags.add(arrayOf("alt", ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady, isDraft)
}
}
}

View File

@ -20,7 +20,6 @@
*/
package com.vitorpamplona.quartz.events
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
@ -78,6 +77,7 @@ class NIP24Factory {
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String? = null,
onReady: (Result) -> Unit,
) {
val senderPublicKey = signer.pubKey
@ -93,8 +93,17 @@ class NIP24Factory {
markAsSensitive = markAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
isDraft = draftTag != null,
nip94attachments = nip94attachments,
) { senderMessage ->
if (draftTag != null) {
onReady(
Result(
msg = senderMessage,
wraps = listOf(),
),
)
} else {
createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps ->
onReady(
Result(
@ -105,6 +114,7 @@ class NIP24Factory {
}
}
}
}
fun createReactionWithinGroup(
content: String,
@ -155,49 +165,4 @@ class NIP24Factory {
}
}
}
fun createTextNoteNIP24(
msg: String,
to: List<HexKey>,
signer: NostrSigner,
replyTos: List<String>? = null,
mentions: List<String>? = null,
addresses: List<ATag>?,
extraTags: List<String>?,
zapReceiver: List<ZapSplitSetup>? = null,
markAsSensitive: Boolean = false,
replyingTo: String?,
root: String?,
directMentions: Set<HexKey>,
zapRaiserAmount: Long? = null,
geohash: String? = null,
onReady: (Result) -> Unit,
) {
val senderPublicKey = signer.pubKey
TextNoteEvent.create(
msg = msg,
signer = signer,
replyTos = replyTos,
mentions = mentions,
zapReceiver = zapReceiver,
root = root,
extraTags = extraTags,
addresses = addresses,
directMentions = directMentions,
replyingTo = replyingTo,
markAsSensitive = markAsSensitive,
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
) { senderMessage ->
createWraps(senderMessage, to.plus(senderPublicKey).toSet(), signer) { wraps ->
onReady(
Result(
msg = senderMessage,
wraps = wraps,
),
)
}
}
}
}

View File

@ -80,6 +80,7 @@ class PollNoteEvent(
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
isDraft: Boolean,
onReady: (PollNoteEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
@ -112,7 +113,7 @@ class PollNoteEvent(
}
tags.add(arrayOf("alt", ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft)
}
}
}

View File

@ -126,6 +126,7 @@ class PrivateDmEvent(
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
isDraft: Boolean,
onReady: (PrivateDmEvent) -> Unit,
) {
var message = msg
@ -167,7 +168,7 @@ class PrivateDmEvent(
tags.add(arrayOf("alt", ALT))
signer.nip04Encrypt(message, recipientPubKey) { content ->
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady, isDraft)
}
}
}

View File

@ -60,6 +60,7 @@ class TextNoteEvent(
forkedFrom: Event? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
isDraft: Boolean,
onReady: (TextNoteEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
@ -124,7 +125,7 @@ class TextNoteEvent(
}
}
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady, isDraft)
}
}
}

View File

@ -171,6 +171,10 @@ class ExternalSignerLauncher(
"sign_event",
22242,
),
Permission(
"sign_event",
31234,
),
Permission(
"nip04_encrypt",
),

View File

@ -32,6 +32,7 @@ abstract class NostrSigner(val pubKey: HexKey) {
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
isDraft: Boolean = false,
)
abstract fun nip04Encrypt(

View File

@ -40,7 +40,13 @@ class NostrSignerExternal(
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
isDraft: Boolean,
) {
if (isDraft) {
unsignedEvent(createdAt, kind, tags, content, onReady)
return
}
val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey()
val event =
@ -86,6 +92,28 @@ class NostrSignerExternal(
}
}
fun <T : Event> unsignedEvent(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
val id = Event.generateId(pubKey, createdAt, kind, tags, content)
onReady(
EventFactory.create(
id.toHexKey(),
pubKey,
createdAt,
kind,
tags,
content,
"",
) as T,
)
}
override fun nip04Encrypt(
decryptedContent: String,
toPublicKey: HexKey,

View File

@ -38,9 +38,15 @@ class NostrSignerInternal(val keyPair: KeyPair) : NostrSigner(keyPair.pubKey.toH
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
isDraft: Boolean,
) {
if (keyPair.privKey == null) return
if (isDraft) {
unsignedEvent(createdAt, kind, tags, content, onReady)
return
}
if (isUnsignedPrivateEvent(kind, tags)) {
// this is a private zap
signPrivateZap(createdAt, kind, tags, content, onReady)
@ -82,6 +88,30 @@ class NostrSignerInternal(val keyPair: KeyPair) : NostrSigner(keyPair.pubKey.toH
)
}
fun <T : Event> unsignedEvent(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
if (keyPair.privKey == null) return
val id = Event.generateId(pubKey, createdAt, kind, tags, content)
onReady(
EventFactory.create(
id.toHexKey(),
pubKey,
createdAt,
kind,
tags,
content,
"",
) as T,
)
}
override fun nip04Encrypt(
decryptedContent: String,
toPublicKey: HexKey,