Porównaj commity

...

24 Commity

Autor SHA1 Wiadomość Data
Vitor Pamplona 638dba770d Moves the creation of Draft Notes to the IO Thread 2024-04-02 17:49:52 -04:00
Vitor Pamplona 4d7de6bc19 No need to switch to IO this early in the process 2024-04-02 16:08:59 -04:00
Vitor Pamplona fbf676bdb2 Reduces recomposition of the hash verification 2024-04-02 16:08:38 -04:00
Vitor Pamplona 793780f02c
Merge pull request #821 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-04-02 10:02:21 -04:00
Crowdin Bot adf31ed115 New Crowdin translations by GitHub Action 2024-04-02 13:54:47 +00:00
Vitor Pamplona 96e434bfcf Fixes the lack of following mark on chats 2024-04-02 09:52:52 -04:00
Vitor Pamplona c7563c938d Minor refactoring 2024-04-02 09:42:01 -04:00
Vitor Pamplona 4380393c5b No need to update UUID anymore. After deletion the draft can just be updated. 2024-04-02 09:23:08 -04:00
Vitor Pamplona 8125a7dabb Correctly moving the Reply line out of the renderer. 2024-04-01 18:32:36 -04:00
Vitor Pamplona e898d58239 Finishing the rendering of card notes for DMs and live chats 2024-04-01 17:07:27 -04:00
Vitor Pamplona 29a43f82e6 Fixes lack of blurhash in some videos 2024-04-01 15:26:02 -04:00
Vitor Pamplona 469b9c6acb Only changes the keep playing status if different 2024-04-01 15:25:49 -04:00
Vitor Pamplona 38d1bf9aec Updates firebase 2024-04-01 15:01:39 -04:00
Vitor Pamplona 18b57b8ac8 Reactivating hold to edit draft. 2024-04-01 14:14:58 -04:00
Vitor Pamplona 7fc43c96d6
Merge pull request #819 from jiftechnify/meta-parser-benchmark
Add benchmark for MetaTagsParser
2024-03-29 17:46:35 -04:00
Vitor Pamplona fc7d3a9519
Merge pull request #820 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-03-29 17:46:12 -04:00
Crowdin Bot 1667a78bb9 New Crowdin translations by GitHub Action 2024-03-29 21:44:07 +00:00
Vitor Pamplona 5fbd6c25d0 Fixes layout of the reply row in chats. 2024-03-29 17:41:13 -04:00
Vitor Pamplona d079d511e8 Fixes markers for DMs 2024-03-29 17:40:51 -04:00
Vitor Pamplona 6e1418cd54 - Adds a Draft Screen
- Migrating drafts to new architecture where the Draft Event is sent to the screen instead of the inner event.
- Fixes lots of deletion and indexing bugs
2024-03-29 17:38:31 -04:00
Vitor Pamplona cd84c07fcc Adds k-tag to the Deletion events. 2024-03-28 19:02:23 -04:00
jiftechnify 6eb2fbfa2f
reduce creation of StringBuffers in meta tags parsing 2024-03-27 22:54:06 +09:00
jiftechnify fc6f460063
fix contact link in relay information dialog 2024-03-27 22:52:16 +09:00
jiftechnify 442cdfdf2a
move MetaTagsParser to common module 2024-03-27 21:19:52 +09:00
68 zmienionych plików z 10221 dodań i 1009 usunięć

Wyświetl plik

@ -61,6 +61,7 @@ import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileServersEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
@ -845,24 +846,18 @@ 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))
}
fun delete(note: Note) {
delete(listOf(note))
}
suspend fun delete(notes: List<Note>) {
fun delete(notes: List<Note>) {
if (!isWriteable()) return
val myNotes = notes.filter { it.author == userProfile() }.mapNotNull { it.event?.id() }
val myEvents = notes.filter { it.author == userProfile() }
val myNoteVersions = myEvents.mapNotNull { it.event as? Event }
if (myNotes.isNotEmpty()) {
DeletionEvent.create(myNotes, signer) {
if (myNoteVersions.isNotEmpty()) {
DeletionEvent.create(myNoteVersions, signer) {
Client.send(it)
LocalCache.justConsume(it, null)
}
@ -905,17 +900,10 @@ class Account(
fun broadcast(note: Note) {
note.event?.let {
if (note.isDraft()) {
val drafts = LocalCache.getDrafts(it.id())
drafts.forEach { draftNote ->
broadcast(draftNote)
}
if (it is WrappedEvent && it.host != null) {
it.host?.let { hostEvent -> Client.send(hostEvent) }
} else {
if (it is WrappedEvent && it.host != null) {
it.host?.let { hostEvent -> Client.send(hostEvent) }
} else {
Client.send(it)
}
Client.send(it)
}
}
}
@ -1365,11 +1353,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it, relayList = relayList)
@ -1427,11 +1417,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it, relayList = relayList)
@ -1453,7 +1445,21 @@ class Account(
}
}
fun sendPost(
fun deleteDraft(draftTag: String) {
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
LocalCache.getAddressableNoteIfExists(key)?.let {
val noteEvent = it.event
if (noteEvent is DraftEvent) {
noteEvent.createDeletedEvent(signer) {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
delete(it)
}
}
suspend fun sendPost(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
@ -1495,11 +1501,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it, relayList = relayList)
@ -1586,11 +1594,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it, relayList = relayList)
@ -1638,11 +1648,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
@ -1683,11 +1695,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
@ -1755,11 +1769,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
@ -1801,11 +1817,13 @@ class Account(
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())
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
broadcastPrivately(it)
@ -1891,7 +1909,7 @@ class Account(
Client.send(event)
LocalCache.justConsume(event, null)
DeletionEvent.create(listOf(event.id), signer) { event2 ->
DeletionEvent.createForVersionOnly(listOf(event), signer) { event2 ->
Client.send(event2)
LocalCache.justConsume(event2, null)
}
@ -2324,14 +2342,18 @@ class Account(
}
fun cachedDecryptContent(note: Note): String? {
val event = note.event
return cachedDecryptContent(note.event)
}
fun cachedDecryptContent(event: EventInterface?): String? {
if (event == null) return null
return if (event is PrivateDmEvent && isWriteable()) {
event.cachedContentFor(signer)
} else if (event is LnZapRequestEvent && event.isPrivateZap() && isWriteable()) {
event.cachedPrivateZap()?.content
} else {
event?.content()
event.content()
}
}

Wyświetl plik

@ -105,6 +105,7 @@ import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentSetOf
@ -129,7 +130,6 @@ 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)
@ -142,34 +142,6 @@ 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" }
@ -379,7 +351,7 @@ object LocalCache {
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
@ -462,13 +434,7 @@ object LocalCache {
return
}
val repository = event.repository()?.toTag()
val replyTo =
event
.tagsWithoutCitations()
.filter { it != repository }
.mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
// println("New GitReply ${event.id} for ${replyTo.firstOrNull()?.event?.id()} ${event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.firstOrNull()}")
@ -506,7 +472,7 @@ object LocalCache {
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
@ -541,7 +507,7 @@ object LocalCache {
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
@ -550,6 +516,58 @@ object LocalCache {
}
}
fun computeReplyTo(event: Event): List<Note> {
return when (event) {
is PollNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is WikiNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is LongTextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is GitReplyEvent -> event.tagsWithoutCitations().filter { it != event.repository()?.toTag() }.mapNotNull { checkGetOrCreateNote(it) }
is TextNoteEvent -> event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is ChatMessageEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is LnZapEvent ->
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList())
is LnZapRequestEvent ->
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is BadgeProfilesEvent ->
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
is BadgeAwardEvent -> event.awardDefinition().map { getOrCreateAddressableNote(it) }
is PrivateDmEvent -> event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
is RepostEvent ->
event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is GenericRepostEvent ->
event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is CommunityPostApprovalEvent -> event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) }
is ReactionEvent ->
event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is ReportEvent ->
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
is ChannelMessageEvent ->
event
.tagsWithoutCitations()
.filter { it != event.channel() }
.mapNotNull { checkGetOrCreateNote(it) }
is LiveActivitiesChatMessageEvent ->
event
.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
is DraftEvent -> {
event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().mapNotNull { checkGetOrCreateAddressableNote(it.toTag()) }
}
else -> emptyList<Note>()
}
}
fun consume(
event: PollNoteEvent,
relay: Relay? = null,
@ -570,7 +588,7 @@ object LocalCache {
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
@ -791,9 +809,7 @@ object LocalCache {
// Already processed this event.
if (note.event?.id() == event.id()) return
val replyTo =
event.badgeAwardEvents().mapNotNull { checkGetOrCreateNote(it) } +
event.badgeAwardDefinitions().map { getOrCreateAddressableNote(it) }
val replyTo = computeReplyTo(event)
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, replyTo)
@ -812,7 +828,7 @@ object LocalCache {
// ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val awardDefinition = event.awardDefinition().map { getOrCreateAddressableNote(it) }
val awardDefinition = computeReplyTo(event)
note.loadEvent(event, author, awardDefinition)
@ -872,6 +888,8 @@ object LocalCache {
val note = getOrCreateAddressableNote(event.address())
val author = getOrCreateUser(event.pubKey)
val replyTos = computeReplyTo(event)
if (version.event == null) {
version.loadEvent(event, author, emptyList())
version.moveAllReferencesTo(note)
@ -886,7 +904,7 @@ object LocalCache {
if (note.event?.id() == event.id()) return
if (event.createdAt > (note.createdAt() ?: 0)) {
note.loadEvent(event, author, emptyList())
note.loadEvent(event, author, replyTos)
refreshObservers(note)
}
@ -923,7 +941,7 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -947,52 +965,112 @@ object LocalCache {
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey) {
// reverts the add
val mentions =
deleteNote.event
?.tags()
?.filter { it.firstOrNull() == "p" }
?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
mentions?.forEach { user -> user.removeReport(deleteNote) }
// Counts the replies
deleteNote.replyTo?.forEach { masterNote ->
masterNote.removeReply(deleteNote)
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) }
(deleteNote.event as? LiveActivitiesChatMessageEvent)?.activity()?.let {
getChannelIfExists(it.toTag())?.removeNote(deleteNote)
}
if (deleteNote.event is PrivateDmEvent) {
val author = deleteNote.author
val recipient =
(deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let {
checkGetOrCreateUser(it)
}
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
recipient.removeMessage(author, deleteNote)
}
}
notes.remove(deleteNote.idHex)
deleteNote(deleteNote)
deletedAtLeastOne = true
}
}
val addressList = event.deleteAddresses()
val addressSet = addressList.toSet()
addressList
.mapNotNull { getAddressableNoteIfExists(it.toTag()) }
.forEach { deleteNote ->
// must be the same author
if (deleteNote.author?.pubkeyHex == event.pubKey && (deleteNote.createdAt() ?: 0) < event.createdAt) {
// Counts the replies
deleteNote(deleteNote)
addressables.remove(deleteNote.idHex)
deletedAtLeastOne = true
}
}
notes.forEach { key, note ->
val noteEvent = note.event
if (noteEvent is AddressableEvent && noteEvent.address() in addressSet) {
if (noteEvent.pubKey() == event.pubKey && noteEvent.createdAt() <= event.createdAt) {
deleteNote(note)
deletedAtLeastOne = true
}
}
}
if (deletedAtLeastOne) {
// refreshObservers()
val note = Note(event.id)
note.loadEvent(event, getOrCreateUser(event.pubKey), emptyList())
refreshObservers(note)
}
}
private fun deleteNote(deleteNote: Note) {
val deletedEvent = deleteNote.event
val mentions =
deleteNote.event
?.tags()
?.filter { it.firstOrNull() == "p" }
?.mapNotNull { it.getOrNull(1) }
?.mapNotNull { checkGetOrCreateUser(it) }
mentions?.forEach { user -> user.removeReport(deleteNote) }
// Counts the replies
deleteNote.replyTo?.forEach { masterNote ->
masterNote.removeReply(deleteNote)
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
deleteNote.channelHex()?.let { getChannelIfExists(it)?.removeNote(deleteNote) }
(deletedEvent as? LiveActivitiesChatMessageEvent)?.activity()?.let {
getChannelIfExists(it.toTag())?.removeNote(deleteNote)
}
if (deletedEvent is PrivateDmEvent) {
val author = deleteNote.author
val recipient =
deletedEvent.verifiedRecipientPubKey()?.let {
checkGetOrCreateUser(it)
}
if (recipient != null && author != null) {
author.removeMessage(recipient, deleteNote)
recipient.removeMessage(author, deleteNote)
}
}
if (deletedEvent is DraftEvent) {
deletedEvent.allCache().forEach {
it?.let {
deindexDraftAsRealEvent(deleteNote, it)
}
}
}
if (deletedEvent is WrappedEvent) {
deleteWraps(deletedEvent)
}
notes.remove(deleteNote.idHex)
}
fun deleteWraps(event: WrappedEvent) {
event.host?.let {
// seal
getNoteIfExists(it.id)?.let {
val noteEvent = it.event
if (noteEvent is WrappedEvent) {
deleteWraps(noteEvent)
}
}
notes.remove(it.id)
}
}
@ -1006,9 +1084,7 @@ object LocalCache {
// ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val repliesTo =
event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -1028,9 +1104,7 @@ object LocalCache {
// ${formattedDateTime(event.createdAt)}")
val author = getOrCreateUser(event.pubKey)
val repliesTo =
event.boostedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -1052,7 +1126,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val communities = event.communities()
val eventsApproved = event.approvedEvents().mapNotNull { checkGetOrCreateNote(it) }
val eventsApproved = computeReplyTo(event)
val repliesTo = communities.map { getOrCreateAddressableNote(it) }
@ -1071,9 +1145,7 @@ object LocalCache {
if (note.event != null) return
val author = getOrCreateUser(event.pubKey)
val repliesTo =
event.originalPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -1101,9 +1173,7 @@ object LocalCache {
if (note.event != null) return
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
val repliesTo =
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -1202,11 +1272,7 @@ object LocalCache {
return
}
val replyTo =
event
.tagsWithoutCitations()
.filter { it != event.channel() }
.mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
@ -1245,11 +1311,7 @@ object LocalCache {
return
}
val replyTo =
event
.tagsWithoutCitations()
.filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
@ -1279,15 +1341,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo =
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) } +
(
(zapRequest.event as? LnZapRequestEvent)?.taggedAddresses()?.map {
getOrCreateAddressableNote(it)
}
?: emptySet<Note>()
)
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -1308,9 +1362,7 @@ object LocalCache {
val author = getOrCreateUser(event.pubKey)
val mentions = event.zappedAuthor().mapNotNull { checkGetOrCreateUser(it) }
val repliesTo =
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -1512,7 +1564,7 @@ object LocalCache {
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
val repliesTo = event.taggedEvents().mapNotNull { checkGetOrCreateNote(it) }
val repliesTo = computeReplyTo(event)
note.loadEvent(event, author, repliesTo)
@ -2046,7 +2098,112 @@ object LocalCache {
event: DraftEvent,
relay: Relay?,
) {
consumeBaseReplaceable(event, relay)
if (!event.isDeleted()) {
consumeBaseReplaceable(event, relay)
event.allCache().forEach {
it?.let {
indexDraftAsRealEvent(event, it)
}
}
}
}
fun indexDraftAsRealEvent(
draftWrap: DraftEvent,
draft: Event,
) {
val note = getOrCreateAddressableNote(draftWrap.address())
val author = getOrCreateUser(draftWrap.pubKey)
when (draft) {
is PrivateDmEvent -> {
draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient ->
author.addMessage(recipient, note)
recipient.addMessage(author, note)
}
}
is ChatMessageEvent -> {
val recipientsHex = draft.recipientsPubKey().plus(draftWrap.pubKey).toSet()
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
if (recipients.isNotEmpty()) {
recipients.forEach {
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
val authorGroup =
if (groupMinusRecipient.isEmpty()) {
// note to self
ChatroomKey(persistentSetOf(it.pubkeyHex))
} else {
ChatroomKey(groupMinusRecipient.toImmutableSet())
}
it.addMessage(authorGroup, note)
}
}
}
is ChannelMessageEvent -> {
draft.channel()?.let { channelId ->
checkGetOrCreateChannel(channelId)?.let { channel ->
channel.addNote(note)
}
}
}
is TextNoteEvent -> {
val replyTo = computeReplyTo(draft)
val author = getOrCreateUser(draftWrap.pubKey)
note.loadEvent(draftWrap, author, replyTo)
replyTo.forEach { it.addReply(note) }
}
}
}
fun deindexDraftAsRealEvent(
draftWrap: Note,
draft: Event,
) {
val author = draftWrap.author ?: return
when (draft) {
is PrivateDmEvent -> {
draft.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }?.let { recipient ->
author.removeMessage(recipient, draftWrap)
recipient.removeMessage(author, draftWrap)
}
}
is ChatMessageEvent -> {
val recipientsHex = draft.recipientsPubKey().plus(author.pubkeyHex).toSet()
val recipients = recipientsHex.mapNotNull { checkGetOrCreateUser(it) }.toSet()
if (recipients.isNotEmpty()) {
recipients.forEach {
val groupMinusRecipient = recipientsHex.minus(it.pubkeyHex)
val authorGroup =
if (groupMinusRecipient.isEmpty()) {
// note to self
ChatroomKey(persistentSetOf(it.pubkeyHex))
} else {
ChatroomKey(groupMinusRecipient.toImmutableSet())
}
it.removeMessage(authorGroup, draftWrap)
}
}
}
is ChannelMessageEvent -> {
draft.channel()?.let { channelId ->
checkGetOrCreateChannel(channelId)?.let { channel ->
channel.removeNote(draftWrap)
}
}
}
is TextNoteEvent -> {
val replyTo = computeReplyTo(draft)
replyTo.forEach { it.removeReply(draftWrap) }
}
}
}
fun justConsume(

Wyświetl plik

@ -47,6 +47,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
@ -97,6 +98,14 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
override fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
deletionAddressables: Set<ATag>,
): Boolean {
val thisEvent = event
return deletionAddressables.contains(address) || (thisEvent != null && deletionEvents.contains(thisEvent.id()))
}
}
@Stable
@ -184,12 +193,7 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun isDraft(): Boolean {
event?.let {
return it.sig().isBlank()
}
return false
}
fun isDraft() = event is DraftEvent
fun loadEvent(
event: Event,
@ -935,6 +939,14 @@ open class Note(val idHex: String) {
createOrDestroyFlowSync(false)
}
}
open fun wasOrShouldBeDeletedBy(
deletionEvents: Set<HexKey>,
deletionAddressables: Set<ATag>,
): Boolean {
val thisEvent = event
return deletionEvents.contains(idHex) || (thisEvent is AddressableEvent && deletionAddressables.contains(thisEvent.address()))
}
}
@Stable

Wyświetl plik

@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.RepostEvent
import kotlin.time.measureTimedValue
@ -78,7 +80,7 @@ class ThreadAssembler {
val note = LocalCache.checkGetOrCreateNote(noteId) ?: return emptySet<Note>()
if (note.event != null) {
val thread = mutableSetOf<Note>()
val thread = OnlyLatestVersionSet()
val threadRoot = searchRoot(note, thread) ?: note
@ -87,7 +89,7 @@ class ThreadAssembler {
// did not added them.
note.replies.forEach { loadDown(it, thread) }
thread.toSet()
thread
} else {
setOf(note)
}
@ -109,3 +111,87 @@ class ThreadAssembler {
}
}
}
class OnlyLatestVersionSet : MutableSet<Note> {
val map = hashMapOf<ATag, Long>()
val set = hashSetOf<Note>()
override fun add(element: Note): Boolean {
val loadedCreatedAt = element.createdAt()
val noteEvent = element.event
return if (element is AddressableNote && loadedCreatedAt != null) {
innerAdd(element.address, element, loadedCreatedAt)
} else if (noteEvent is AddressableEvent && loadedCreatedAt != null) {
innerAdd(noteEvent.address(), element, loadedCreatedAt)
} else {
set.add(element)
}
}
private fun innerAdd(
address: ATag,
element: Note,
loadedCreatedAt: Long,
): Boolean {
val existing = map.get(address)
return if (existing == null) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
if (loadedCreatedAt > existing) {
map.put(address, loadedCreatedAt)
set.add(element)
} else {
false
}
}
}
override fun addAll(elements: Collection<Note>): Boolean {
return elements.map { add(it) }.any()
}
override val size: Int
get() = set.size
override fun clear() {
set.clear()
map.clear()
}
override fun isEmpty(): Boolean {
return set.isEmpty()
}
override fun containsAll(elements: Collection<Note>): Boolean {
return set.containsAll(elements)
}
override fun contains(element: Note): Boolean {
return set.contains(element)
}
override fun iterator(): MutableIterator<Note> {
return set.iterator()
}
override fun retainAll(elements: Collection<Note>): Boolean {
return set.retainAll(elements)
}
override fun removeAll(elements: Collection<Note>): Boolean {
return elements.map { remove(it) }.any()
}
override fun remove(element: Note): Boolean {
element.address()?.let {
map.remove(it)
}
(element.event as? AddressableEvent)?.address()?.let {
map.remove(it)
}
return set.remove(element)
}
}

Wyświetl plik

@ -26,8 +26,6 @@ import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Stable
object UrlCachedPreviewer {
@ -37,46 +35,44 @@ object UrlCachedPreviewer {
suspend fun previewInfo(
url: String,
onReady: suspend (UrlPreviewState) -> Unit,
) = withContext(Dispatchers.IO) {
) {
cache[url]?.let {
onReady(it)
return@withContext
return
}
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override suspend fun onComplete(urlInfo: UrlInfoItem) =
withContext(Dispatchers.IO) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return@withContext
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) =
withContext(Dispatchers.IO) {
cache[url]?.let {
override suspend fun onComplete(urlInfo: UrlInfoItem) {
cache[url]?.let {
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
onReady(it)
return@withContext
return
}
}
val state =
if (urlInfo.fetchComplete() && urlInfo.url == url) {
UrlPreviewState.Loaded(urlInfo)
} else {
UrlPreviewState.Empty
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
cache.put(url, state)
onReady(state)
}
override suspend fun onFailed(throwable: Throwable) {
cache[url]?.let {
onReady(it)
return
}
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
cache.put(url, state)
onReady(state)
}
},
)
.fetchUrlPreview()

Wyświetl plik

@ -277,6 +277,18 @@ class User(val pubkeyHex: String) {
}
}
fun removeMessage(
room: ChatroomKey,
msg: Note,
) {
checkNotInMainThread()
val privateChatroom = getOrCreatePrivateChatroom(room)
if (msg in privateChatroom.roomMessages) {
privateChatroom.removeMessageSync(msg)
liveSet?.innerMessages?.invalidateData()
}
}
fun addRelayBeingUsed(
relay: Relay,
eventTime: Long,

Wyświetl plik

@ -200,8 +200,6 @@ class Nip96Uploader(val account: Account?) {
nip98Header(server.apiUrl)?.let { requestBuilder.addHeader("Authorization", it) }
println(server.apiUrl.removeSuffix("/") + "/$hash.$extension")
val request =
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")

Wyświetl plik

@ -145,7 +145,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds = listOf(ReportEvent.KIND),
kinds = listOf(DraftEvent.KIND, ReportEvent.KIND),
authors = listOf(account.userProfile().pubkeyHex),
since =
latestEOSEs.users[account.userProfile()]
@ -230,16 +230,6 @@ 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,
@ -277,20 +267,14 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
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
if (!event.isDeleted()) {
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()
// decrypts
event.cachedDraft(account.signer) {}
LocalCache.justConsume(it, relay)
tag?.let { lTag ->
LocalCache.addDraft(lTag, event.id(), it.id())
}
LocalCache.justConsume(event, relay)
}
}
@ -376,7 +360,6 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
createAccountSettingsFilter(),
createAccountLastPostsListFilter(),
createOtherAccountsBaseFilter(),
createDraftsFilter(),
)
.ifEmpty { null }
} else {

Wyświetl plik

@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.Subscription
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
@ -293,7 +294,13 @@ abstract class NostrDataSource(val debugName: String) {
eventId: String,
relay: Relay,
) {
LocalCache.getNoteIfExists(eventId)?.addRelay(relay)
val note = LocalCache.getNoteIfExists(eventId)
val noteEvent = note?.event
if (noteEvent is AddressableEvent) {
LocalCache.getAddressableNoteIfExists(noteEvent.address().toTag())?.addRelay(relay)
} else {
note?.addRelay(relay)
}
}
open fun markAsEOSE(

Wyświetl plik

@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.service.relays.EOSETime
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GitReplyEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@ -57,29 +58,45 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
}
return groupByEOSEPresence(addressesToWatch).map {
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
listOf(
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
TextNoteEvent.KIND,
ReactionEvent.KIND,
RepostEvent.KIND,
GenericRepostEvent.KIND,
ReportEvent.KIND,
LnZapEvent.KIND,
PollNoteEvent.KIND,
CommunityPostApprovalEvent.KIND,
LiveActivitiesChatMessageEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 1000,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("a" to it.mapNotNull { it.address()?.toTag() }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}
}.flatten()
}
private fun createAddressFilter(): List<TypedFilter>? {
@ -147,6 +164,20 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
limit = 1000,
),
),
TypedFilter(
types = COMMON_FEED_TYPES,
filter =
JsonFilter(
kinds =
listOf(
DeletionEvent.KIND,
),
tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it),
// Max amount of "replies" to download on a specific event.
limit = 10,
),
),
)
}.flatten()
}

Wyświetl plik

@ -21,18 +21,14 @@
package com.vitorpamplona.amethyst.service.previews
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
withContext(Dispatchers.IO) {
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
try {
fetch(timeOut)
} catch (t: Throwable) {
if (t is CancellationException) throw t
callback?.onFailed(t)
}
private suspend fun fetch(timeOut: Int = 30000) {

Wyświetl plik

@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.previews
import com.vitorpamplona.amethyst.commons.preview.MetaTag
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import com.vitorpamplona.amethyst.service.HttpClientManager
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.Dispatchers

Wyświetl plik

@ -34,11 +34,8 @@ 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(
@ -49,10 +46,7 @@ fun NewPollOption(
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = {
pollViewModel.pollOptions.remove(optionIndex)
pollViewModel.viewModelScope.launch(Dispatchers.IO) {
pollViewModel.saveDraft()
}
pollViewModel.removePollOption(optionIndex)
},
) {
Icon(
@ -66,10 +60,7 @@ fun NewPollOption(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = {
pollViewModel.pollOptions[optionIndex] = it
pollViewModel.viewModelScope.launch(Dispatchers.IO) {
pollViewModel.saveDraft()
}
pollViewModel.updatePollOption(optionIndex, it)
},
label = {
Text(

Wyświetl plik

@ -119,7 +119,6 @@ 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
@ -177,7 +176,6 @@ 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
@ -206,15 +204,18 @@ fun NewPostView(
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
LaunchedEffect(key1 = postViewModel.draftTag) {
launch(Dispatchers.IO) {
postViewModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
postViewModel.sendPost(relayList = relayList, localDraft = postViewModel.draftTag)
postViewModel.sendDraft(relayList = relayList)
}
}
}
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
@ -366,7 +367,7 @@ fun NewPostView(
}
}
if (enableMessageInterface) {
if (postViewModel.wantsDirectMessage) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
@ -596,10 +597,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
postViewModel.toggleMarkAsSensitive()
}
AddGeoHash(postViewModel) {
@ -846,10 +844,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.title,
onValueChange = {
postViewModel.title = it
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
postViewModel.updateTitle(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
@ -886,16 +881,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
modifier = Modifier.fillMaxWidth(),
value = postViewModel.price,
onValueChange = {
runCatching {
if (it.text.isEmpty()) {
postViewModel.price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
postViewModel.price = it
}
}
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
postViewModel.updatePrice(it)
},
placeholder = {
Text(
@ -961,10 +947,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = {
postViewModel.condition = conditionTypes[it].first
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
postViewModel.updateCondition(conditionTypes[it].first)
},
modifier =
Modifier
@ -1030,10 +1013,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
?: "",
options = categoryOptions,
onSelect = {
postViewModel.category = TextFieldValue(categoryTypes[it].second)
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
},
modifier =
Modifier
@ -1070,10 +1050,7 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.locationText,
onValueChange = {
postViewModel.locationText = it
postViewModel.viewModelScope.launch(Dispatchers.IO) {
postViewModel.saveDraft()
}
postViewModel.updateLocation(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {

Wyświetl plik

@ -49,12 +49,15 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageEvent
@ -84,7 +87,8 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var draftTag: String = UUID.randomUUID().toString()
var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@ -192,8 +196,17 @@ open class NewPostViewModel() : ViewModel() {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
if (draft != null) {
loadFromDraft(draft, accountViewModel)
val noteEvent = draft?.event
val noteAuthor = draft?.author
if (draft != null && noteEvent is DraftEvent && noteAuthor != null) {
accountViewModel.createTempDraftNote(noteEvent, noteAuthor) { innerNote ->
val oldTag = (draft.event as? AddressableEvent)?.dTag()
if (oldTag != null) {
draftTag = oldTag
}
loadFromDraft(innerNote, accountViewModel)
}
} else {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@ -227,14 +240,6 @@ open class NewPostViewModel() : ViewModel() {
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
@ -313,16 +318,13 @@ open class NewPostViewModel() : ViewModel() {
accountViewModel: AccountViewModel,
) {
Log.d("draft", draft.event!!.toJson())
draftTag = LocalCache.drafts.filter {
it.value.any { it.eventId == draft.event?.id() }
}.keys.firstOrNull() ?: draftTag
val draftEvent = draft.event ?: return
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()
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
forwardZapTo = Split()
localfowardZapTo.forEach {
val user = LocalCache.getOrCreateUser(it[1])
@ -332,9 +334,9 @@ open class NewPostViewModel() : ViewModel() {
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()
wantsToMarkAsSensitive = draftEvent.tags().any { it.size > 1 && it[0] == "content-warning" }
wantsToAddGeoHash = draftEvent.tags().any { it.size > 1 && it[0] == "g" }
val zapraiser = draftEvent.tags().filter { it.size > 1 && it[0] == "zapraiser" }
wantsZapraiser = zapraiser.isNotEmpty()
zapRaiserAmount = null
if (wantsZapraiser) {
@ -342,25 +344,34 @@ open class NewPostViewModel() : ViewModel() {
}
eTags =
draft.event?.tags()?.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }?.mapNotNull {
draftEvent.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])
}
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
pTags =
draftEvent.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 {
draftEvent.tags().filter { it.size > 3 && (it[0] == "e" || it[0] == "a") && it.get(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 {
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}?.firstOrNull()
}.firstOrNull()
if (originalNote == null) {
originalNote =
draftEvent.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
@ -368,14 +379,14 @@ open class NewPostViewModel() : ViewModel() {
wantsForwardZapTo = true
}
val polls = draft.event?.tags()?.filter { it.size > 1 && it[0] == "poll_option" } ?: emptyList()
val polls = draftEvent.tags().filter { it.size > 1 && it[0] == "poll_option" }
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()
val minMax = draftEvent.tags().filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") }
minMax.forEach {
if (it[0] == "value_maximum") {
valueMaximum = it[1].toInt()
@ -384,33 +395,56 @@ open class NewPostViewModel() : ViewModel() {
}
}
wantsProduct = draft.event?.kind() == 30402
wantsProduct = draftEvent.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() ?: "")
title = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "title" }.map { it[1] }?.firstOrNull() ?: "")
price = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "price" }.map { it[1] }?.firstOrNull() ?: "")
category = TextFieldValue(draftEvent.tags().filter { it.size > 1 && it[0] == "t" }.map { it[1] }?.firstOrNull() ?: "")
locationText = TextFieldValue(draftEvent.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()
it.value == draftEvent.tags().filter { it.size > 1 && it[0] == "condition" }.map { it[1] }.firstOrNull()
} ?: ClassifiedsEvent.CONDITION.USED_LIKE_NEW
wantsDirectMessage = draftEvent is PrivateDmEvent || draftEvent is ChatMessageEvent
draftEvent.subject()?.let {
subject = TextFieldValue()
}
message =
if (draft.event is PrivateDmEvent) {
val event = draft.event as PrivateDmEvent
TextFieldValue(event.cachedContentFor(accountViewModel.account.signer) ?: "")
if (draftEvent is PrivateDmEvent) {
val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recepientNpub")
TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "")
} else {
TextFieldValue(draft.event?.content() ?: "")
TextFieldValue(draftEvent.content())
}
nip24 = draft.event is ChatMessageEvent
requiresNIP24 = draftEvent is ChatMessageEvent
nip24 = draftEvent is ChatMessageEvent
if (draftEvent is ChatMessageEvent) {
toUsers =
TextFieldValue(
draftEvent.recipientsPubKey().mapNotNull { runCatching { Hex.decode(it).toNpub() }.getOrNull() }.joinToString(", ") { "@$it" },
)
}
urlPreview = findUrlInMessage()
}
fun sendPost(
relayList: List<Relay>? = null,
localDraft: String? = null,
) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList, localDraft) }
fun sendPost(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, null)
accountViewModel?.deleteDraft(draftTag)
cancel()
}
}
fun sendDraft(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, draftTag)
}
}
private suspend fun innerSendPost(
@ -422,8 +456,7 @@ open class NewPostViewModel() : ViewModel() {
return
}
val tagger =
NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
val tagger = NewMessageTagger(message.text, pTags, eTags, originalNote?.channelHex(), accountViewModel!!)
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
@ -526,6 +559,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
@ -599,19 +633,19 @@ open class NewPostViewModel() : ViewModel() {
} else {
if (wantsPoll) {
account?.sendPoll(
tagger.message,
tagger.eTags,
tagger.pTags,
pollOptions,
valueMaximum,
valueMinimum,
consensusThreshold,
closedAt,
zapReceiver,
wantsToMarkAsSensitive,
localZapRaiserAmount,
relayList,
geoHash,
message = tagger.message,
replyTo = tagger.eTags,
mentions = tagger.pTags,
pollOptions = pollOptions,
valueMaximum = valueMaximum,
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
closedAt = closedAt,
zapReceiver = zapReceiver,
wantsToMarkAsSensitive = wantsToMarkAsSensitive,
zapRaiserAmount = localZapRaiserAmount,
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
@ -673,9 +707,6 @@ open class NewPostViewModel() : ViewModel() {
)
}
}
if (localDraft == null) {
cancel()
}
}
fun upload(
@ -759,7 +790,6 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = null
isUploadingImage = false
pTags = null
eTags = null
wantsDirectMessage = false
@ -777,6 +807,9 @@ open class NewPostViewModel() : ViewModel() {
wantsProduct = false
condition = ClassifiedsEvent.CONDITION.USED_LIKE_NEW
locationText = TextFieldValue("")
title = TextFieldValue("")
category = TextFieldValue("")
price = TextFieldValue("")
wantsForwardZapTo = false
@ -788,13 +821,16 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
userSuggestionAnchor = null
userSuggestionsMainMessage = null
originalNote = null
draftTag = UUID.randomUUID().toString()
NostrSearchEventOrUserDataSource.clear()
}
fun deleteDraft() {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel?.deleteDraft(draftTag)
}
NostrSearchEventOrUserDataSource.clear()
}
open fun findUrlInMessage(): String? {
@ -809,8 +845,8 @@ open class NewPostViewModel() : ViewModel() {
pTags = pTags?.filter { it != userToRemove }
}
open suspend fun saveDraft() {
draftTextChanges.send("")
private fun saveDraft() {
draftTextChanges.trySend("")
}
open fun updateMessage(it: TextFieldValue) {
@ -836,9 +872,7 @@ open class NewPostViewModel() : ViewModel() {
}
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
open fun updateToUsers(it: TextFieldValue) {
@ -862,16 +896,12 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
open fun updateSubject(it: TextFieldValue) {
subject = it
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
open fun updateZapForwardTo(it: TextFieldValue) {
@ -898,9 +928,7 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
open fun autocompleteWithUser(item: User) {
@ -947,9 +975,7 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
@ -1020,9 +1046,7 @@ open class NewPostViewModel() : ViewModel() {
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
},
onError = {
@ -1067,9 +1091,7 @@ open class NewPostViewModel() : ViewModel() {
}
urlPreview = findUrlInMessage()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
},
onError = {
@ -1090,7 +1112,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() }
saveDraft()
}
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
@ -1116,9 +1138,7 @@ open class NewPostViewModel() : ViewModel() {
nip24 = !nip24
}
if (message.text.isNotBlank()) {
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
}
@ -1139,9 +1159,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
fun updateMaxZapAmountForPoll(textMax: String) {
@ -1161,9 +1179,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
viewModelScope.launch(Dispatchers.IO) {
saveDraft()
}
saveDraft()
}
fun checkMinMax() {
@ -1182,6 +1198,60 @@ open class NewPostViewModel() : ViewModel() {
) {
forwardZapTo.updatePercentage(index, sliderValue)
}
fun updateZapRaiserAmount(newAmount: Long?) {
zapRaiserAmount = newAmount
saveDraft()
}
fun removePollOption(optionIndex: Int) {
pollOptions.remove(optionIndex)
saveDraft()
}
fun updatePollOption(
optionIndex: Int,
text: String,
) {
pollOptions[optionIndex] = text
saveDraft()
}
fun toggleMarkAsSensitive() {
wantsToMarkAsSensitive = !wantsToMarkAsSensitive
saveDraft()
}
fun updateTitle(it: TextFieldValue) {
title = it
saveDraft()
}
fun updatePrice(it: TextFieldValue) {
runCatching {
if (it.text.isEmpty()) {
price = TextFieldValue("")
} else if (it.text.toLongOrNull() != null) {
price = it
}
}
saveDraft()
}
fun updateCondition(newCondition: ClassifiedsEvent.CONDITION) {
condition = newCondition
saveDraft()
}
fun updateCategory(value: TextFieldValue) {
category = value
saveDraft()
}
fun updateLocation(it: TextFieldValue) {
locationText = it
saveDraft()
}
}
enum class GeohashPrecision(val digits: Int) {

Wyświetl plik

@ -883,13 +883,17 @@ fun ControlWhenPlayerIsActive(
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video
// being played in the mutex is not visible.
view.keepScreenOn = isPlaying
if (view.keepScreenOn != isPlaying) {
view.keepScreenOn = isPlaying
}
}
}
controller.addListener(listener)
onDispose {
view.keepScreenOn = false
if (view.keepScreenOn) {
view.keepScreenOn = false
}
controller.removeListener(listener)
}
}

Wyświetl plik

@ -39,7 +39,6 @@ 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
@ -47,8 +46,6 @@ 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(
@ -97,12 +94,9 @@ fun ZapRaiserRequest(
onValueChange = {
runCatching {
if (it.isEmpty()) {
newPostViewModel.zapRaiserAmount = null
newPostViewModel.updateZapRaiserAmount(null)
} else {
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
}
newPostViewModel.viewModelScope.launch(Dispatchers.IO) {
newPostViewModel.saveDraft()
newPostViewModel.updateZapRaiserAmount(it.toLongOrNull())
}
}
},

Wyświetl plik

@ -501,8 +501,6 @@ private fun AddedImageFeatures(
ImageUrlWithDownloadButton(content.url, showImage)
}
} else {
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
when (painter.value) {
null,
is AsyncImagePainter.State.Loading,
@ -528,24 +526,32 @@ private fun AddedImageFeatures(
}
}
is AsyncImagePainter.State.Success -> {
if (content.hash != null) {
LaunchedEffect(key1 = content.url) {
launch(Dispatchers.IO) {
val newVerifiedHash = verifyHash(content)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
}
verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) }
ShowHash(content, verifiedModifier)
}
else -> {}
}
}
}
@Composable
fun ShowHash(
content: MediaUrlContent,
verifiedModifier: Modifier,
) {
var verifiedHash by remember(content.url) { mutableStateOf<Boolean?>(null) }
if (content.hash != null) {
LaunchedEffect(key1 = content.url) {
val newVerifiedHash = verifyHash(content)
if (newVerifiedHash != verifiedHash) {
verifiedHash = newVerifiedHash
}
}
}
verifiedHash?.let { HashVerificationSymbol(it, verifiedModifier) }
}
fun aspectRatio(dim: String?): Float? {
if (dim == null) return null
if (dim == "0x0") return null

Wyświetl plik

@ -18,6 +18,39 @@
* 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
package com.vitorpamplona.amethyst.ui.dal
data class Drafts(val mainId: String, val eventId: String)
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.quartz.events.DraftEvent
class DraftEventsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return collection.filterTo(HashSet()) {
acceptableEvent(it)
}
}
override fun feed(): List<Note> {
val drafts =
LocalCache.addressables.filterIntoSet { _, note ->
acceptableEvent(note)
}
return sort(drafts)
}
fun acceptableEvent(it: Note): Boolean {
val noteEvent = it.event
return noteEvent is DraftEvent && noteEvent.pubKey == account.userProfile().pubkeyHex
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(DefaultFeedOrder)
}
}

Wyświetl plik

@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreenByAuthor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.GeoHashScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
@ -214,6 +215,7 @@ fun AppNavigation(
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
composable(Route.Drafts.route, content = { DraftListScreen(accountViewModel, nav) })
Route.Profile.let { route ->
composable(

Wyświetl plik

@ -185,6 +185,8 @@ private fun RenderTopRouteBar(
Route.Discover.base -> DiscoveryTopBar(followLists, drawerState, accountViewModel, nav)
Route.Notification.base -> NotificationTopBar(followLists, drawerState, accountViewModel, nav)
Route.Settings.base -> TopBarWithBackButton(stringResource(id = R.string.application_preferences), navPopBack)
Route.Bookmarks.base -> TopBarWithBackButton(stringResource(id = R.string.bookmarks), navPopBack)
Route.Drafts.base -> TopBarWithBackButton(stringResource(id = R.string.drafts), navPopBack)
else -> {
if (id != null) {
when (currentRoute) {

Wyświetl plik

@ -88,7 +88,6 @@ 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
@ -462,12 +461,6 @@ 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
@ -492,6 +485,15 @@ fun ListContent(
route = Route.Bookmarks.route,
)
NavigationRow(
title = stringResource(R.string.drafts),
icon = Route.Drafts.icon,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
drawerState = drawerState,
route = Route.Drafts.route,
)
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
@ -588,18 +590,6 @@ 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)) },

Wyświetl plik

@ -25,12 +25,14 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.IsInPublicChatChannel
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import kotlinx.collections.immutable.persistentSetOf
@ -40,18 +42,51 @@ fun routeFor(
note: Note,
loggedIn: User,
): String? {
val noteEvent = note.event
val noteEvent = note.event ?: return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
if (
noteEvent is ChannelMessageEvent ||
noteEvent is ChannelCreateEvent ||
noteEvent is ChannelMetadataEvent
) {
note.channelHex()?.let {
return routeFor(noteEvent, loggedIn)
}
fun routeFor(
noteEvent: EventInterface,
loggedIn: User,
): String? {
if (noteEvent is DraftEvent) {
val innerEvent = noteEvent.preCachedDraft(loggedIn.pubkeyHex)
if (innerEvent is IsInPublicChatChannel) {
innerEvent.channel()?.let {
return "Channel/$it"
}
} else if (innerEvent is LiveActivitiesEvent) {
innerEvent.address().toTag().let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (innerEvent is LiveActivitiesChatMessageEvent) {
innerEvent.activity()?.toTag()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (innerEvent is ChatroomKeyable) {
val room = innerEvent.chatroomKey(loggedIn.pubkeyHex)
loggedIn.createChatroom(room)
return "Room/${room.hashCode()}"
} else if (innerEvent is AddressableEvent) {
return "Note/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}"
} else {
return "Note/${URLEncoder.encode(noteEvent.id(), "utf-8")}"
}
} else if (noteEvent is IsInPublicChatChannel) {
noteEvent.channel()?.let {
return "Channel/$it"
}
} else if (noteEvent is LiveActivitiesEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()?.let {
} else if (noteEvent is ChannelCreateEvent) {
return "Channel/${noteEvent.id}"
} else if (noteEvent is LiveActivitiesEvent) {
noteEvent.address().toTag().let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activity()?.toTag()?.let {
return "Channel/${URLEncoder.encode(it, "utf-8")}"
}
} else if (noteEvent is ChatroomKeyable) {
@ -59,9 +94,11 @@ fun routeFor(
loggedIn.createChatroom(room)
return "Room/${room.hashCode()}"
} else if (noteEvent is CommunityDefinitionEvent) {
return "Community/${URLEncoder.encode(note.idHex, "utf-8")}"
return "Community/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}"
} else if (noteEvent is AddressableEvent) {
return "Note/${URLEncoder.encode(noteEvent.address().toTag(), "utf-8")}"
} else {
return "Note/${URLEncoder.encode(note.idHex, "utf-8")}"
return "Note/${URLEncoder.encode(noteEvent.id(), "utf-8")}"
}
return null

Wyświetl plik

@ -148,6 +148,13 @@ sealed class Route(
contentDescriptor = R.string.route_home,
)
object Drafts :
Route(
route = "Drafts",
icon = R.drawable.ic_topics,
contentDescriptor = R.string.drafts,
)
object Profile :
Route(
route = "User/{id}",

Wyświetl plik

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

Wyświetl plik

@ -69,7 +69,6 @@ import com.vitorpamplona.amethyst.ui.layouts.ChatHeaderLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.emptyLineItemModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.encoders.HexKey
@ -77,6 +76,7 @@ import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.DraftEvent
@Composable
fun ChatroomHeaderCompose(
@ -102,12 +102,24 @@ fun ChatroomComposeChannelOrUser(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } }
if (baseNote.event is DraftEvent) {
ObserveDraftEvent(baseNote, accountViewModel) {
val channelHex by remember(it) { derivedStateOf { it.channelHex() } }
if (channelHex != null) {
ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav)
if (channelHex != null) {
ChatroomChannel(channelHex!!, it, accountViewModel, nav)
} else {
ChatroomPrivateMessages(it, accountViewModel, nav)
}
}
} else {
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
val channelHex by remember(baseNote) { derivedStateOf { baseNote.channelHex() } }
if (channelHex != null) {
ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav)
} else {
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
}
}
}
@ -128,9 +140,7 @@ private fun ChatroomPrivateMessages(
if (room != null) {
UserRoomCompose(baseNote, room, accountViewModel, nav)
} else {
Box(emptyLineItemModifier) {
// Makes sure just a max amount of objects are loaded.
}
BlankNote()
}
}
}

Wyświetl plik

@ -25,6 +25,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -64,7 +65,6 @@ 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
@ -84,6 +84,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.chatAuthorBox
import com.vitorpamplona.amethyst.ui.theme.chatAuthorImage
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@ -91,6 +92,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
@ -103,9 +105,9 @@ fun ChatroomMessageCompose(
innerQuote: Boolean = false,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) {
WatchBlockAndReport(
@ -122,9 +124,9 @@ fun ChatroomMessageCompose(
canPreview,
parentBackgroundColor,
accountViewModel,
newPostViewModel,
nav,
onWantsToReply,
onWantsToEditDraft,
)
}
}
@ -139,9 +141,9 @@ fun NormalChatNote(
canPreview: Boolean = true,
parentBackgroundColor: MutableState<Color>? = null,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
val drawAuthorInfo by
remember(note) {
@ -255,11 +257,11 @@ fun NormalChatNote(
innerQuote,
backgroundBubbleColor,
onWantsToReply,
onWantsToEditDraft,
canPreview,
availableBubbleSize,
showDetails,
accountViewModel,
newPostViewModel,
nav,
)
}
@ -269,8 +271,9 @@ fun NormalChatNote(
note = note,
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
onWantsToEditDraft = { onWantsToEditDraft(note) },
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
)
}
}
@ -284,11 +287,11 @@ private fun RenderBubble(
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
canPreview: Boolean,
availableBubbleSize: MutableState<Int>,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
) {
val bubbleSize = remember { mutableIntStateOf(0) }
@ -315,10 +318,10 @@ private fun RenderBubble(
backgroundBubbleColor,
bubbleSize,
onWantsToReply,
onWantsToEditDraft,
canPreview,
showDetails,
accountViewModel,
newPostViewModel,
nav,
)
}
@ -334,10 +337,10 @@ private fun MessageBubbleLines(
backgroundBubbleColor: MutableState<Color>,
bubbleSize: MutableState<Int>,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
canPreview: Boolean,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
) {
if (drawAuthorInfo) {
@ -345,24 +348,29 @@ private fun MessageBubbleLines(
baseNote,
alignment,
accountViewModel.settings.showProfilePictures.value,
accountViewModel,
nav,
)
}
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
)
if (baseNote.event !is DraftEvent) {
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
)
}
NoteRow(
note = baseNote,
canPreview = canPreview,
innerQuote = innerQuote,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
@ -407,12 +415,12 @@ private fun RenderReplyRow(
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
if (!innerQuote && note.replyTo?.lastOrNull() != null) {
RenderReply(note, backgroundBubbleColor, accountViewModel, newPostViewModel, nav, onWantsToReply)
RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft)
}
}
@ -421,9 +429,9 @@ private fun RenderReply(
note: Note,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val replyTo =
@ -440,9 +448,9 @@ private fun RenderReply(
innerQuote = true,
parentBackgroundColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
)
}
}
@ -453,19 +461,28 @@ private fun NoteRow(
note: Note,
canPreview: Boolean,
innerQuote: Boolean,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
when (note.event) {
is ChannelCreateEvent -> {
RenderCreateChannelNote(note)
}
is ChannelMetadataEvent -> {
RenderChangeChannelMetadataNote(note)
}
else -> {
is ChannelCreateEvent -> RenderCreateChannelNote(note)
is ChannelMetadataEvent -> RenderChangeChannelMetadataNote(note)
is DraftEvent ->
RenderDraftEvent(
note,
canPreview,
innerQuote,
onWantsToReply,
onWantsToEditDraft,
backgroundBubbleColor,
accountViewModel,
nav,
)
else ->
RenderRegularTextNote(
note,
canPreview,
@ -474,7 +491,43 @@ private fun NoteRow(
accountViewModel,
nav,
)
}
}
}
}
@Composable
private fun RenderDraftEvent(
note: Note,
canPreview: Boolean,
innerQuote: Boolean,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
ObserveDraftEvent(note, accountViewModel) {
Column {
RenderReplyRow(
note = it,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
)
NoteRow(
note = it,
canPreview = canPreview,
innerQuote = innerQuote,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@ -654,6 +707,7 @@ private fun DrawAuthorInfo(
baseNote: Note,
alignment: Arrangement.Horizontal,
loadProfilePicture: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
baseNote.author?.let {
@ -667,7 +721,7 @@ private fun DrawAuthorInfo(
nav("User/${baseNote.author?.pubkeyHex}")
},
) {
WatchAndDisplayUser(it, loadProfilePicture, nav)
WatchAndDisplayUser(it, loadProfilePicture, accountViewModel, nav)
}
}
}
@ -676,11 +730,23 @@ private fun DrawAuthorInfo(
private fun WatchAndDisplayUser(
author: User,
loadProfilePicture: Boolean,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val userState by author.live().userMetadataInfo.observeAsState()
UserIcon(author.pubkeyHex, userState?.picture, loadProfilePicture)
Box(chatAuthorBox, contentAlignment = Alignment.TopEnd) {
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.picture,
userName = userState?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
)
ObserveAndDisplayFollowingMark(author.pubkeyHex, Size5dp, accountViewModel)
}
if (userState != null) {
DisplayMessageUsername(userState?.bestName() ?: author.pubkeyDisplayHex(), userState?.tags ?: EmptyTagList)

Wyświetl plik

@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
@ -70,19 +71,11 @@ fun LoadDecryptedContentOrNull(
accountViewModel: AccountViewModel,
inner: @Composable (String?) -> Unit,
) {
var decryptedContent by
remember(note.event) {
mutableStateOf(
accountViewModel.cachedDecrypt(note),
)
val decryptedContent by
produceState(initialValue = accountViewModel.cachedDecrypt(note), key1 = note.event?.id()) {
accountViewModel.decrypt(note) { value = it }
}
if (decryptedContent == null) {
LaunchedEffect(key1 = decryptedContent) {
accountViewModel.decrypt(note) { decryptedContent = it }
}
}
inner(decryptedContent)
}

Wyświetl plik

@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -54,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
@ -84,6 +84,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioHeader
import com.vitorpamplona.amethyst.ui.note.types.RenderAudioTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
@ -91,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
@ -118,7 +121,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
@ -138,9 +140,11 @@ import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.FhirResourceEvent
import com.vitorpamplona.quartz.events.FileHeaderEvent
@ -166,8 +170,6 @@ import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@Composable
@ -244,7 +246,7 @@ fun AcceptableNote(
nav = nav,
)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) {
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) {
showPopup,
->
CheckNewAndRenderNote(
@ -279,9 +281,7 @@ fun AcceptableNote(
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel, newPostViewModel = null) {
showPopup,
->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote = baseNote,
routeForLastRead = routeForLastRead,
@ -466,7 +466,7 @@ fun InnerNoteWithReactions(
}
}
val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent
val isNotRepost = baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent && baseNote.event !is DraftEvent
if (isNotRepost) {
if (makeItShort) {
@ -483,6 +483,10 @@ fun InnerNoteWithReactions(
nav = nav,
)
}
} else {
if (baseNote.event is DraftEvent) {
Spacer(modifier = DoubleVertSpacer)
}
}
}
@ -521,16 +525,6 @@ fun NoteBody(
Spacer(modifier = Modifier.height(3.dp))
}
if (!makeItShort) {
ReplyRow(
baseNote,
unPackReply,
backgroundColor,
accountViewModel,
nav,
)
}
RenderNoteRow(
baseNote = baseNote,
backgroundColor = backgroundColor,
@ -538,6 +532,7 @@ fun NoteBody(
canPreview = canPreview,
editState = editState,
quotesLeft = quotesLeft,
unPackReply = unPackReply,
accountViewModel = accountViewModel,
nav = nav,
)
@ -557,6 +552,7 @@ private fun RenderNoteRow(
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
unPackReply: Boolean,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -566,6 +562,7 @@ private fun RenderNoteRow(
is AppDefinitionEvent -> RenderAppDefinition(baseNote, accountViewModel, nav)
is AudioTrackEvent -> RenderAudioTrack(baseNote, accountViewModel, nav)
is AudioHeaderEvent -> RenderAudioHeader(baseNote, accountViewModel, nav)
is DraftEvent -> RenderDraft(baseNote, backgroundColor, accountViewModel, nav)
is ReactionEvent -> RenderReaction(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is RepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
is GenericRepostEvent -> RenderRepost(baseNote, quotesLeft, backgroundColor, accountViewModel, nav)
@ -613,6 +610,18 @@ private fun RenderNoteRow(
nav,
)
}
is ChatMessageEvent -> {
RenderChatMessage(
baseNote,
makeItShort,
canPreview,
quotesLeft,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
is ClassifiedsEvent -> {
RenderClassifieds(
noteEvent,
@ -638,6 +647,7 @@ private fun RenderNoteRow(
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
accountViewModel,
nav,
@ -667,8 +677,8 @@ private fun RenderNoteRow(
nav,
)
}
else -> {
RenderTextEvent(
is ChannelMessageEvent ->
RenderChannelMessage(
baseNote,
makeItShort,
canPreview,
@ -678,6 +688,76 @@ private fun RenderNoteRow(
accountViewModel,
nav,
)
is LiveActivitiesChatMessageEvent ->
RenderLiveActivityChatMessage(
baseNote,
makeItShort,
canPreview,
quotesLeft,
backgroundColor,
editState,
accountViewModel,
nav,
)
else -> {
RenderTextEvent(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
}
}
@Composable
fun ObserveDraftEvent(
note: Note,
accountViewModel: AccountViewModel,
render: @Composable (Note) -> Unit,
) {
val noteState by note.live().metadata.observeAsState()
val noteEvent = noteState?.note?.event as? DraftEvent ?: return
val innerNote = produceCachedStateAsync(cache = accountViewModel.draftNoteCache, key = noteEvent)
innerNote.value?.let {
render(it)
}
}
@Composable
fun RenderDraft(
note: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
ObserveDraftEvent(note, accountViewModel) {
val edits = remember { mutableStateOf(GenericLoadable.Empty<EditState>()) }
RenderNoteRow(
baseNote = it,
backgroundColor = backgroundColor,
makeItShort = false,
canPreview = true,
editState = edits,
quotesLeft = 3,
unPackReply = true,
accountViewModel = accountViewModel,
nav = nav,
)
val zapSplits = remember(it.event) { it.event?.hasZapSplitSetup() }
if (zapSplits == true) {
Spacer(modifier = HalfDoubleVertSpacer)
DisplayZapSplits(it.event!!, false, accountViewModel, nav)
}
}
}
@ -715,81 +795,7 @@ fun getGradient(backgroundColor: MutableState<Color>): Brush {
}
@Composable
private fun ReplyRow(
note: Note,
unPackReply: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = note.event
val showReply by
remember(note) {
derivedStateOf {
noteEvent is BaseTextNoteEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
val showChannelInfo by
remember(note) {
derivedStateOf {
if (noteEvent is ChannelMessageEvent || noteEvent is LiveActivitiesChatMessageEvent) {
note.channelHex()
} else {
null
}
}
}
showChannelInfo?.let {
ChannelHeader(
channelHex = it,
showVideo = false,
sendToChannel = true,
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
if (showReply) {
val replyingDirectlyTo =
remember(note) {
if (noteEvent is BaseTextNoteEvent) {
val replyingTo = noteEvent.replyingToAddressOrEvent()
if (replyingTo != null) {
val newNote = accountViewModel.getNoteIfExists(replyingTo)
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
newNote
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
}
if (replyingDirectlyTo != null && unPackReply) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
} else if (showChannelInfo != null) {
val replies = remember { note.replyTo?.toImmutableList() }
val mentions =
remember {
(note.event as? BaseTextNoteEvent)?.mentions()?.toImmutableList() ?: persistentListOf()
}
ReplyInformationChannel(replies, mentions, accountViewModel, nav)
}
}
}
@Composable
private fun ReplyNoteComposition(
fun ReplyNoteComposition(
replyingDirectlyTo: Note,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,

Wyświetl plik

@ -86,7 +86,6 @@ 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
@ -135,16 +134,20 @@ val externalLinkForNote = { note: Note ->
fun LongPressToQuickAction(
baseNote: Note,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
content: @Composable (() -> Unit) -> Unit,
) {
val popupExpanded = remember { mutableStateOf(false) }
val showPopup = remember { { popupExpanded.value = true } }
val hidePopup = remember { { popupExpanded.value = false } }
content(showPopup)
NoteQuickActionMenu(baseNote, popupExpanded.value, hidePopup, accountViewModel, newPostViewModel)
NoteQuickActionMenu(
note = baseNote,
popupExpanded = popupExpanded.value,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = {},
)
}
@Composable
@ -153,24 +156,54 @@ fun NoteQuickActionMenu(
popupExpanded: Boolean,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel?,
nav: (String) -> Unit,
) {
val editDraftDialog = remember { mutableStateOf(false) }
if (editDraftDialog.value) {
NewPostView(
onClose = {
editDraftDialog.value = false
},
accountViewModel = accountViewModel,
draft = note,
nav = { },
)
}
NoteQuickActionMenu(
note = note,
popupExpanded = popupExpanded,
onDismiss = onDismiss,
onWantsToEditDraft = { editDraftDialog.value = true },
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun NoteQuickActionMenu(
note: Note,
popupExpanded: Boolean,
onDismiss: () -> Unit,
onWantsToEditDraft: () -> Unit,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
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,
onWantsToEditDraft,
)
}
@ -207,29 +240,17 @@ 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>,
onWantsToEditDraft: () -> Unit,
) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f)
@ -300,22 +321,6 @@ 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)
@ -376,31 +381,41 @@ private fun RenderMainPopup(
onDismiss()
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringResource(R.string.quick_action_share),
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
context.getString(R.string.quick_action_share_browser_link),
)
}
if (isOwnNote && note.isDraft()) {
NoteQuickActionItem(
Icons.Default.Edit,
stringResource(R.string.edit_draft),
) {
onDismiss()
onWantsToEditDraft()
}
} else {
NoteQuickActionItem(
icon = Icons.Default.Share,
label = stringResource(R.string.quick_action_share),
) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(
Intent.EXTRA_TEXT,
externalLinkForNote(note),
)
putExtra(
Intent.EXTRA_TITLE,
context.getString(R.string.quick_action_share_browser_link),
)
}
val shareIntent =
Intent.createChooser(
sendIntent,
context.getString(R.string.quick_action_share),
)
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
val shareIntent =
Intent.createChooser(
sendIntent,
context.getString(R.string.quick_action_share),
)
ContextCompat.startActivity(context, shareIntent, null)
onDismiss()
}
}
if (!isOwnNote) {

Wyświetl plik

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

Wyświetl plik

@ -57,6 +57,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@ -175,20 +176,22 @@ class AddBountyAmountViewModel : ViewModel() {
val newValue = nextAmount.text.trim().toLongOrNull()
if (newValue != null) {
account?.sendPost(
message = newValue.toString(),
replyTo = listOfNotNull(bounty),
mentions = listOfNotNull(bounty?.author),
tags = listOf("bounty-added-reward"),
wantsToMarkAsSensitive = false,
replyingTo = null,
root = null,
directMentions = setOf(),
forkedFrom = null,
draftTag = null,
)
viewModelScope.launch {
account?.sendPost(
message = newValue.toString(),
replyTo = listOfNotNull(bounty),
mentions = listOfNotNull(bounty?.author),
tags = listOf("bounty-added-reward"),
wantsToMarkAsSensitive = false,
replyingTo = null,
root = null,
directMentions = setOf(),
forkedFrom = null,
draftTag = null,
)
nextAmount = TextFieldValue("")
nextAmount = TextFieldValue("")
}
}
}
@ -237,10 +240,8 @@ fun AddBountyAmountDialog(
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost()
onClose()
}
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged(),
)

Wyświetl plik

@ -237,7 +237,7 @@ fun NoteDropDownMenu(
},
)
HorizontalDivider(thickness = DividerThickness)
if (note.isDraft()) {
if (state.isLoggedUser && note.isDraft()) {
DropdownMenuItem(
text = { Text(stringResource(R.string.edit_draft)) },
onClick = {

Wyświetl plik

@ -0,0 +1,85 @@
/**
* 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.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.events.ChannelMessageEvent
@Composable
fun RenderChannelMessage(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = note.event
val showChannelInfo =
remember(noteEvent) {
if (noteEvent is ChannelMessageEvent) {
noteEvent.channel()
} else {
null
}
}
showChannelInfo?.let {
ChannelHeader(
channelHex = it,
showVideo = false,
sendToChannel = true,
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
RenderTextEvent(
note,
makeItShort,
canPreview,
quotesLeft,
unPackReply = false,
backgroundColor,
editState,
accountViewModel,
nav,
)
}

Wyświetl plik

@ -0,0 +1,83 @@
/**
* 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.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.events.ChatroomKeyable
@Composable
fun RenderChatMessage(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val userRoom by
remember(note) {
derivedStateOf {
(note.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex)
}
}
userRoom?.let {
if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) {
ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) {
routeFor(note, accountViewModel.userProfile())?.let {
nav(it)
}
}
Spacer(modifier = StdVertSpacer)
}
}
RenderTextEvent(
note,
makeItShort,
canPreview,
quotesLeft,
unPackReply = false,
backgroundColor,
editState,
accountViewModel,
nav,
)
}

Wyświetl plik

@ -67,6 +67,7 @@ fun FileHeaderDisplay(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),

Wyświetl plik

@ -0,0 +1,85 @@
/**
* 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.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
@Composable
fun RenderLiveActivityChatMessage(
note: Note,
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
backgroundColor: MutableState<Color>,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = note.event
val showChannelInfo =
remember(noteEvent) {
if (noteEvent is LiveActivitiesChatMessageEvent) {
noteEvent.activity()?.toTag()
} else {
null
}
}
showChannelInfo?.let {
ChannelHeader(
channelHex = it,
showVideo = false,
sendToChannel = true,
modifier = MaterialTheme.colorScheme.replyModifier.padding(10.dp),
accountViewModel = accountViewModel,
nav = nav,
)
Spacer(modifier = StdVertSpacer)
}
RenderTextEvent(
note,
makeItShort,
canPreview,
quotesLeft,
unPackReply = false,
backgroundColor,
editState,
accountViewModel,
nav,
)
}

Wyświetl plik

@ -20,11 +20,14 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -33,9 +36,13 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.PollNote
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@ -47,6 +54,7 @@ fun RenderPoll(
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
unPackReply: Boolean,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -54,6 +62,38 @@ fun RenderPoll(
val noteEvent = note.event as? PollNoteEvent ?: return
val eventContent = noteEvent.content()
val showReply by
remember(note) {
derivedStateOf {
noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
if (showReply) {
val replyingDirectlyTo =
remember(note) {
if (noteEvent is BaseTextNoteEvent) {
val replyingTo = noteEvent.replyingToAddressOrEvent()
if (replyingTo != null) {
val newNote = accountViewModel.getNoteIfExists(replyingTo)
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
newNote
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
}
if (replyingDirectlyTo != null) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
}
if (makeItShort && accountViewModel.isLoggedUser(note.author)) {
Text(
text = eventContent,

Wyświetl plik

@ -20,25 +20,35 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomHeader
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@ -57,6 +67,24 @@ fun RenderPrivateMessage(
) {
val noteEvent = note.event as? PrivateDmEvent ?: return
val userRoom by
remember(note) {
derivedStateOf {
(note.event as? ChatroomKeyable)?.chatroomKey(accountViewModel.userProfile().pubkeyHex)
}
}
userRoom?.let {
if (it.users.size > 1 || (it.users.size == 1 && note.author == accountViewModel.account.userProfile())) {
ChatroomHeader(it, MaterialTheme.colorScheme.replyModifier.padding(10.dp), accountViewModel) {
routeFor(note, accountViewModel.userProfile())?.let {
nav(it)
}
}
Spacer(modifier = StdVertSpacer)
}
}
val withMe = remember { noteEvent.with(accountViewModel.userProfile().pubkeyHex) }
if (withMe) {
LoadDecryptedContent(note, accountViewModel) { eventContent ->

Wyświetl plik

@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.note.types
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -37,9 +38,13 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent
import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@ -52,11 +57,46 @@ fun RenderTextEvent(
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
unPackReply: Boolean,
backgroundColor: MutableState<Color>,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val noteEvent = note.event
val showReply by
remember(note) {
derivedStateOf {
noteEvent is BaseTextNoteEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser())
}
}
if (showReply) {
val replyingDirectlyTo =
remember(note) {
if (noteEvent is BaseTextNoteEvent) {
val replyingTo = noteEvent.replyingToAddressOrEvent()
if (replyingTo != null) {
val newNote = accountViewModel.getNoteIfExists(replyingTo)
if (newNote != null && newNote.channelHex() == null && newNote.event?.kind() != CommunityDefinitionEvent.KIND) {
newNote
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
} else {
note.replyTo?.lastOrNull { it.event?.kind() != CommunityDefinitionEvent.KIND }
}
}
if (replyingDirectlyTo != null && unPackReply) {
ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav)
Spacer(modifier = StdVertSpacer)
}
}
LoadDecryptedContent(
note,
accountViewModel,

Wyświetl plik

@ -38,21 +38,22 @@ 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
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.quartz.events.DraftEvent
@Composable
fun RefreshingChatroomFeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: String? = null,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true,
) {
@ -61,11 +62,12 @@ fun RefreshingChatroomFeedView(
RenderChatroomFeedView(
viewModel,
accountViewModel,
newPostViewModel,
listState,
nav,
routeForLastRead,
onWantsToReply,
onWantsToEditDraft,
avoidDraft,
)
}
}
@ -75,11 +77,12 @@ fun RefreshingChatroomFeedView(
fun RenderChatroomFeedView(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: String? = null,
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
@ -95,11 +98,12 @@ fun RenderChatroomFeedView(
ChatroomFeedLoaded(
state,
accountViewModel,
newPostViewModel,
listState,
nav,
routeForLastRead,
onWantsToReply,
onWantsToEditDraft,
avoidDraft,
)
}
is FeedState.Loading -> {
@ -113,11 +117,12 @@ fun RenderChatroomFeedView(
fun ChatroomFeedLoaded(
state: FeedState.Loaded,
accountViewModel: AccountViewModel,
newPostViewModel: NewPostViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: String? = null,
) {
LaunchedEffect(state.feed.value.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1) {
@ -132,14 +137,17 @@ fun ChatroomFeedLoaded(
state = listState,
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
newPostViewModel = newPostViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
)
val noteEvent = item.event
if (avoidDraft == null || noteEvent !is DraftEvent || noteEvent.dTag() != avoidDraft) {
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
)
}
NewSubject(item)
}
}

Wyświetl plik

@ -39,7 +39,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import kotlin.time.ExperimentalTime
@Composable
fun ChatroomListFeedView(
@ -81,7 +80,6 @@ private fun CrossFadeState(
}
}
@OptIn(ExperimentalTime::class)
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,

Wyświetl plik

@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.dal.DiscoverChatFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverCommunityFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverLiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DiscoverMarketplaceFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DraftEventsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
@ -60,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.DeletionEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@ -267,6 +269,16 @@ class NostrBookmarkPrivateFeedViewModel(val account: Account) :
}
}
@Stable
class NostrDraftEventsFeedViewModel(val account: Account) :
FeedViewModel(DraftEventsFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrDraftEventsFeedViewModel : ViewModel> create(modelClass: Class<NostrDraftEventsFeedViewModel>): NostrDraftEventsFeedViewModel {
return NostrDraftEventsFeedViewModel(account) as NostrDraftEventsFeedViewModel
}
}
}
class NostrUserAppRecommendationsFeedViewModel(val user: User) :
FeedViewModel(UserProfileAppRecommendationsFeedFilter(user)) {
class Factory(val user: User) : ViewModelProvider.Factory {
@ -344,9 +356,24 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) :
val oldNotesState = _feedContent.value
if (localFilter is AdditiveFeedFilter && lastFeedKey == localFilter.feedKey()) {
if (oldNotesState is FeedState.Loaded) {
val deletionEvents: List<DeletionEvent> =
newItems.mapNotNull {
val noteEvent = it.event
if (noteEvent is DeletionEvent) noteEvent else null
}
val oldList =
if (deletionEvents.isEmpty()) {
oldNotesState.feed.value
} else {
val deletedEventIds = deletionEvents.flatMapTo(HashSet()) { it.deleteEvents() }
val deletedEventAddresses = deletionEvents.flatMapTo(HashSet()) { it.deleteAddresses() }
oldNotesState.feed.value.filter { !it.wasOrShouldBeDeletedBy(deletedEventIds, deletedEventAddresses) }.toImmutableList()
}
val newList =
localFilter
.updateListWith(oldNotesState.feed.value, newItems)
.updateListWith(oldList, newItems)
.distinctBy { it.idHex }
.toImmutableList()
if (!equalImmutableLists(newList, oldNotesState.feed.value)) {

Wyświetl plik

@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteQuickActionMenu
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderDraft
import com.vitorpamplona.amethyst.ui.note.RenderRepost
import com.vitorpamplona.amethyst.ui.note.elements.DefaultImageHeader
import com.vitorpamplona.amethyst.ui.note.elements.DisplayEditStatus
@ -120,14 +121,17 @@ import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.note.types.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.FileStorageHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.types.RenderAppDefinition
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitPatchEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderPoll
import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval
import com.vitorpamplona.amethyst.ui.note.types.RenderPrivateMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
@ -151,10 +155,12 @@ import com.vitorpamplona.quartz.events.AudioHeaderEvent
import com.vitorpamplona.quartz.events.AudioTrackEvent
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.CommunityDefinitionEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
@ -165,10 +171,12 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.GitPatchEvent
import com.vitorpamplona.quartz.events.GitRepositoryEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import com.vitorpamplona.quartz.events.PinListEvent
import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.RelaySetEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent
@ -478,6 +486,11 @@ fun NoteMaster(
),
) {
Column {
val canPreview =
note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (
(noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) &&
note.channelHex() != null
@ -540,6 +553,8 @@ fun NoteMaster(
RenderGitIssueEvent(baseNote, false, true, quotesLeft = 3, backgroundColor, accountViewModel, nav)
} else if (noteEvent is AppDefinitionEvent) {
RenderAppDefinition(baseNote, accountViewModel, nav)
} else if (noteEvent is DraftEvent) {
RenderDraft(baseNote, backgroundColor, accountViewModel, nav)
} else if (noteEvent is HighlightEvent) {
DisplayHighlight(
noteEvent.quote(),
@ -566,31 +581,55 @@ fun NoteMaster(
nav,
)
} else if (noteEvent is PollNoteEvent) {
val canPreview =
note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
RenderPoll(
baseNote,
false,
canPreview,
quotesLeft = 3,
unPackReply = false,
backgroundColor,
accountViewModel,
nav,
)
} else if (noteEvent is PrivateDmEvent) {
RenderPrivateMessage(
baseNote,
false,
canPreview,
3,
backgroundColor,
accountViewModel,
nav,
)
} else if (noteEvent is ChannelMessageEvent) {
RenderChannelMessage(
baseNote,
false,
canPreview,
3,
backgroundColor,
editState,
accountViewModel,
nav,
)
} else if (noteEvent is LiveActivitiesChatMessageEvent) {
RenderLiveActivityChatMessage(
baseNote,
false,
canPreview,
3,
backgroundColor,
editState,
accountViewModel,
nav,
)
} else {
val canPreview =
note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
RenderTextEvent(
baseNote,
false,
canPreview,
quotesLeft = 3,
unPackReply = false,
backgroundColor,
editState,
accountViewModel,
@ -610,7 +649,14 @@ fun NoteMaster(
ReactionsRow(note, true, editState, accountViewModel, nav)
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel, null)
NoteQuickActionMenu(
note = note,
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
onWantsToEditDraft = { },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@ -865,6 +911,7 @@ private fun RenderWikiHeaderForThreadPreview() {
false,
true,
quotesLeft = 3,
unPackReply = false,
backgroundColor,
editState,
accountViewModel,

Wyświetl plik

@ -38,6 +38,7 @@ import coil.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCacheAsync
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
@ -71,8 +72,10 @@ import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip11RelayInformation
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ChatroomKeyable
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GiftWrapEvent
@ -573,6 +576,10 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
return account.cachedDecryptContent(note)
}
fun cachedDecrypt(event: EventInterface?): String? {
return account.cachedDecryptContent(event)
}
fun decrypt(
note: Note,
onReady: (String) -> Unit,
@ -1313,8 +1320,52 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
suspend fun deleteDraft(draftTag: String) {
val notes = LocalCache.draftNotes(draftTag)
account.delete(notes)
account.deleteDraft(draftTag)
}
fun createTempCachedDraftNote(
noteEvent: DraftEvent,
author: User,
): Note? {
return noteEvent.preCachedDraft(account.signer)?.let { createTempDraftNote(it, author) }
}
fun createTempDraftNote(
noteEvent: DraftEvent,
author: User,
onReady: (Note) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
}
}
fun createTempDraftNote(
innerEvent: Event,
author: User,
): Note {
val note =
if (innerEvent is AddressableEvent) {
AddressableNote(innerEvent.address())
} else {
Note(innerEvent.id)
}
note.loadEvent(innerEvent, author, LocalCache.computeReplyTo(innerEvent))
return note
}
val draftNoteCache = CachedDraftNotes(this)
class CachedDraftNotes(val accountViewModel: AccountViewModel) : GenericBaseCacheAsync<DraftEvent, Note>(20) {
override suspend fun compute(
key: DraftEvent,
onReady: (Note?) -> Unit,
) = withContext(Dispatchers.IO) {
key.cachedDraft(accountViewModel.account.signer) {
val author = LocalCache.getOrCreateUser(key.pubKey)
val note = accountViewModel.createTempDraftNote(it, author)
onReady(note)
}
}
}
val bechLinkCache = CachedLoadedBechLink(this)

Wyświetl plik

@ -64,6 +64,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@ -173,7 +174,6 @@ import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
@Composable
fun ChannelScreen(
@ -210,21 +210,8 @@ 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,
@ -306,71 +293,93 @@ fun ChannelScreen(
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
newPostViewModel = newPostModel,
nav = nav,
routeForLastRead = "Channel/${channel.idHex}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = { replyTo.value = it },
onWantsToEditDraft = {
newPostModel.load(accountViewModel, null, null, null, null, it)
},
)
}
Spacer(modifier = DoubleVertSpacer)
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } }
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
newPostModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
innerSendPost(replyTo, channel, newPostModel, accountViewModel, newPostModel.draftTag)
}
}
}
// LAST ROW
EditFieldRow(newPostModel, isPrivate = false, accountViewModel = accountViewModel) {
scope.launch(Dispatchers.IO) {
val tagger =
NewMessageTagger(
message = newPostModel.message.text,
pTags = listOfNotNull(replyTo.value?.author),
eTags = listOfNotNull(replyTo.value),
channelHex = channel.idHex,
dao = accountViewModel,
)
tagger.run()
val urls = findURLs(tagger.message)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage(
message = tagger.message,
toChannel = channel.idHex,
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
message = tagger.message,
toChannel = channel.address,
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
}
innerSendPost(replyTo, channel, newPostModel, accountViewModel, null)
newPostModel.message = TextFieldValue("")
replyTo.value = null
accountViewModel.deleteDraft(newPostModel.draftTag)
newPostModel.draftTag = UUID.randomUUID().toString()
feedViewModel.sendToTop()
}
}
}
}
private suspend fun innerSendPost(
replyTo: MutableState<Note?>,
channel: Channel,
newPostModel: NewPostViewModel,
accountViewModel: AccountViewModel,
draftTag: String?,
) {
val tagger =
NewMessageTagger(
message = newPostModel.message.text,
pTags = listOfNotNull(replyTo.value?.author),
eTags = listOfNotNull(replyTo.value),
channelHex = channel.idHex,
dao = accountViewModel,
)
tagger.run()
val urls = findURLs(tagger.message)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
if (channel is PublicChatChannel) {
accountViewModel.account.sendChannelMessage(
message = tagger.message,
toChannel = channel.idHex,
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = draftTag,
)
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
message = tagger.message,
toChannel = channel.address,
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = draftTag,
)
}
}
@Composable
fun DisplayReplyingToNote(
replyingNote: Note?,
accountViewModel: AccountViewModel,
newPostModel: NewPostViewModel,
nav: (String) -> Unit,
onCancel: () -> Unit,
) {
@ -389,9 +398,9 @@ fun DisplayReplyingToNote(
null,
innerQuote = true,
accountViewModel = accountViewModel,
newPostViewModel = newPostModel,
nav = nav,
onWantsToReply = {},
onWantsToEditDraft = {},
)
}

Wyświetl plik

@ -57,6 +57,7 @@ import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@ -93,7 +94,6 @@ import com.vitorpamplona.amethyst.ui.actions.PostButton
import com.vitorpamplona.amethyst.ui.actions.ServerOption
import com.vitorpamplona.amethyst.ui.actions.UploadFromGallery
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.DisplayRoomSubject
import com.vitorpamplona.amethyst.ui.note.DisplayUserSetAsSubject
@ -116,8 +116,6 @@ 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
@ -130,7 +128,6 @@ 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(
@ -238,20 +235,8 @@ 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 {
@ -333,54 +318,44 @@ fun ChatroomScreen(
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
newPostViewModel = newPostModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = {
replyTo.value = it
newPostModel.originalNote = it
},
onWantsToEditDraft = {
newPostModel.load(accountViewModel, null, null, null, null, it)
},
)
}
Spacer(modifier = Modifier.height(10.dp))
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, newPostModel, nav) { replyTo.value = null } }
replyTo.value?.let { DisplayReplyingToNote(it, accountViewModel, nav) { replyTo.value = null } }
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = newPostModel.draftTag) {
launch(Dispatchers.IO) {
newPostModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
innerSendPost(newPostModel, room, replyTo, accountViewModel, newPostModel.draftTag)
}
}
}
// LAST ROW
PrivateMessageEditFieldRow(newPostModel, isPrivate = true, accountViewModel) {
scope.launch(Dispatchers.IO) {
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
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() }
if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) {
accountViewModel.account.sendNIP24PrivateMessage(
message = newPostModel.message.text,
toUsers = room.users.toList(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
} else {
accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text,
toUser = room.users.first(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = null,
)
}
newPostModel.message = TextFieldValue("")
replyTo.value = null
feedViewModel.sendToTop()
}
@ -388,6 +363,39 @@ fun ChatroomScreen(
}
}
private fun innerSendPost(
newPostModel: NewPostViewModel,
room: ChatroomKey,
replyTo: MutableState<Note?>,
accountViewModel: AccountViewModel,
dTag: String?,
) {
val urls = findURLs(newPostModel.message.text)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
if (newPostModel.nip24 || room.users.size > 1 || replyTo.value?.event is ChatMessageEvent) {
accountViewModel.account.sendNIP24PrivateMessage(
message = newPostModel.message.text,
toUsers = room.users.toList(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = dTag,
)
} else {
accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text,
toUser = room.users.first(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
draftTag = dTag,
)
}
}
@Composable
fun PrivateMessageEditFieldRow(
channelScreenModel: NewPostViewModel,
@ -580,7 +588,7 @@ fun ChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onClick: () -> Unit,
) {
if (room.users.size == 1) {
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
@ -589,7 +597,7 @@ fun ChatroomHeader(
baseUser = baseUser,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
)
}
}
@ -598,7 +606,7 @@ fun ChatroomHeader(
room = room,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
)
}
}
@ -608,13 +616,13 @@ fun ChatroomHeader(
baseUser: User,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onClick: () -> Unit,
) {
Column(
modifier =
Modifier.fillMaxWidth()
.clickable(
onClick = { nav("User/${baseUser.pubkeyHex}") },
onClick = onClick,
),
) {
Column(
@ -630,14 +638,9 @@ fun ChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser)
ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav)
}
}
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
@ -646,12 +649,10 @@ fun GroupChatroomHeader(
room: ChatroomKey,
modifier: Modifier = StdPadding,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onClick: () -> Unit,
) {
val expanded = remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxWidth().clickable { expanded.value = !expanded.value },
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
) {
Column(
verticalArrangement = Arrangement.Center,
@ -669,15 +670,7 @@ fun GroupChatroomHeader(
DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal)
}
}
if (expanded.value) {
LongRoomHeader(room = room, accountViewModel = accountViewModel, nav = nav)
}
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}

Wyświetl plik

@ -0,0 +1,82 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.ui.screen.NostrDraftEventsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
@Composable
fun DraftListScreen(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val draftFeedViewModel: NostrDraftEventsFeedViewModel =
viewModel(
key = "NostrDraftEventsFeedViewModel",
factory = NostrDraftEventsFeedViewModel.Factory(accountViewModel.account),
)
RenderDraftListScreen(draftFeedViewModel, accountViewModel, nav)
}
@Composable
private fun RenderDraftListScreen(
feedViewModel: NostrDraftEventsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
) {
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(feedViewModel) {
feedViewModel.invalidateData()
}
DisposableEffect(lifeCycleOwner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("DraftList Start")
feedViewModel.invalidateData()
}
if (event == Lifecycle.Event.ON_PAUSE) {
println("DraftList Stop")
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) }
}
RefresheableFeedView(
feedViewModel,
null,
accountViewModel = accountViewModel,
nav = nav,
)
}

Wyświetl plik

@ -228,5 +228,6 @@ val liveStreamTag =
.background(Color.Black)
.padding(horizontal = Size5dp)
val chatAuthorBox = Modifier.size(20.dp)
val chatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape)
val AuthorInfoVideoFeed = Modifier.width(75.dp).padding(end = 15.dp)

Wyświetl plik

@ -622,6 +622,7 @@
<string name="server_did_not_provide_a_url_after_uploading">El servidor no proporcionó una URL después de la carga</string>
<string name="could_not_download_from_the_server">No se pudo descargar el contenido cargado desde el servidor</string>
<string name="could_not_prepare_local_file_to_upload">No se pudo preparar el archivo local para cargar: %1$s</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
<string name="route_home">Inicio</string>
@ -693,4 +694,9 @@
<string name="accessibility_play_username">Reproducir nombre de usuario como audio</string>
<string name="accessibility_scan_qr_code">Escanear código QR</string>
<string name="accessibility_navigate_to_alby">Ir al proveedor externo de monederos Alby</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">No es posible responder a un borrador de nota</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">No es posible citar un borrador de nota</string>
<string name="it_s_not_possible_to_react_to_a_draft_note">No es posible reaccionar a un borrador de nota</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">No es posible zapear un borrador de nota</string>
<string name="draft_note">Borrador de nota</string>
</resources>

Wyświetl plik

@ -622,6 +622,7 @@
<string name="server_did_not_provide_a_url_after_uploading">El servidor no proporcionó una URL después de la subida</string>
<string name="could_not_download_from_the_server">No se pudo descargar el contenido subido desde el servidor</string>
<string name="could_not_prepare_local_file_to_upload">No se pudo preparar el archivo local para subir: %1$s</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
<string name="route_home">Inicio</string>
@ -693,4 +694,9 @@
<string name="accessibility_play_username">Reproducir nombre de usuario como audio</string>
<string name="accessibility_scan_qr_code">Escanear código QR</string>
<string name="accessibility_navigate_to_alby">Ir al proveedor externo de billeteras Alby</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">No es posible responder a un borrador de nota</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">No es posible citar un borrador de nota</string>
<string name="it_s_not_possible_to_react_to_a_draft_note">No es posible reaccionar a un borrador de nota</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">No es posible zapear un borrador de nota</string>
<string name="draft_note">Borrador de nota</string>
</resources>

Wyświetl plik

@ -622,6 +622,7 @@
<string name="server_did_not_provide_a_url_after_uploading">El servidor no proporcionó una URL después de la subida</string>
<string name="could_not_download_from_the_server">No se pudo descargar el contenido subido desde el servidor</string>
<string name="could_not_prepare_local_file_to_upload">No se pudo preparar el archivo local para subir: %1$s</string>
<string name="edit_draft">Editar borrador</string>
<string name="login_with_qr_code">Iniciar sesión con código QR</string>
<string name="route">Ruta</string>
<string name="route_home">Inicio</string>
@ -693,4 +694,9 @@
<string name="accessibility_play_username">Reproducir nombre de usuario como audio</string>
<string name="accessibility_scan_qr_code">Escanear código QR</string>
<string name="accessibility_navigate_to_alby">Ir al proveedor externo de billeteras Alby</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">No es posible responder a un borrador de nota</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">No es posible citar un borrador de nota</string>
<string name="it_s_not_possible_to_react_to_a_draft_note">No es posible reaccionar a un borrador de nota</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">No es posible zapear un borrador de nota</string>
<string name="draft_note">Borrador de nota</string>
</resources>

Wyświetl plik

@ -622,6 +622,7 @@
<string name="server_did_not_provide_a_url_after_uploading">Le serveur n\'a pas fourni d\'URL après le téléversement</string>
<string name="could_not_download_from_the_server">Impossible de télécharger le média depuis le serveur</string>
<string name="could_not_prepare_local_file_to_upload">Impossible de préparer le fichier local à téléverser: %1$s</string>
<string name="edit_draft">Modifier le brouillon</string>
<string name="login_with_qr_code">Se connecter avec un QR Code</string>
<string name="route">Route</string>
<string name="route_home">Accueil</string>
@ -693,4 +694,9 @@
<string name="accessibility_play_username">Écouter le nom d\'utilisateur</string>
<string name="accessibility_scan_qr_code">Scanner le QR code</string>
<string name="accessibility_navigate_to_alby">Accéder au fournisseur de portefeuille tiers Alby</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">Il n\'est pas possible de répondre à un brouillon</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">Il n\'est pas possible de citer un brouillon</string>
<string name="it_s_not_possible_to_react_to_a_draft_note">Il n\'est pas possible de réagir à un brouillon</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">Il n\'est pas possible de zapper un brouillon</string>
<string name="draft_note">Brouillon</string>
</resources>

Wyświetl plik

@ -276,6 +276,7 @@
<string name="report_dialog_title">Blokkeer en rapporteer</string>
<string name="block_only">Blokkeren</string>
<string name="bookmarks">Bladwijzers</string>
<string name="drafts">Concepten</string>
<string name="private_bookmarks">Privé bladwijzers</string>
<string name="public_bookmarks">Publieke Bladwijzers</string>
<string name="add_to_private_bookmarks">Toevoegen aan privé bladwijzers</string>
@ -441,6 +442,8 @@
<string name="connectivity_type_always">Altijd</string>
<string name="connectivity_type_wifi_only">Alleen wifi</string>
<string name="connectivity_type_never">Nooit</string>
<string name="ui_feature_set_type_complete">Voltooid</string>
<string name="ui_feature_set_type_simplified">Vereenvoudigd</string>
<string name="system">Systeem</string>
<string name="light">Licht</string>
<string name="dark">Donker</string>
@ -452,6 +455,8 @@
<string name="automatically_show_url_preview">Automatisch URL preview tonen</string>
<string name="automatically_hide_nav_bars">Volledig scrollen</string>
<string name="automatically_hide_nav_bars_description">Navigatie verbergen bij scrollen</string>
<string name="ui_style">UI modus</string>
<string name="ui_style_description">Kies de stijl van het bericht</string>
<string name="load_image">Afbeelding laden</string>
<string name="spamming_users">Spammers</string>
<string name="muted_button">Geen geluid. Klik om voor geluid</string>
@ -618,6 +623,7 @@
<string name="server_did_not_provide_a_url_after_uploading">Server heeft geen URL opgegeven na uploaden</string>
<string name="could_not_download_from_the_server">Kan de geüploade media niet downloaden van de server</string>
<string name="could_not_prepare_local_file_to_upload">Kon lokaal bestand niet voorbereiden voor upload: %1$s</string>
<string name="edit_draft">Concept bewerken</string>
<string name="login_with_qr_code">Inloggen met QR-Code</string>
<string name="route">Route</string>
<string name="route_home">Beginscherm</string>
@ -689,4 +695,9 @@
<string name="accessibility_play_username">Speel gebruikersnaam af als audio</string>
<string name="accessibility_scan_qr_code">Scan de QR-code</string>
<string name="accessibility_navigate_to_alby">Navigeer naar de externe wallet provider Alby</string>
<string name="it_s_not_possible_to_reply_to_a_draft_note">Het is niet mogelijk om een concept te beantwoorden</string>
<string name="it_s_not_possible_to_quote_to_a_draft_note">Het is niet mogelijk om een concept te citeren</string>
<string name="it_s_not_possible_to_react_to_a_draft_note">Het is niet mogelijk om op een concept te reageren</string>
<string name="it_s_not_possible_to_zap_to_a_draft_note">Het is niet mogelijk om een concept op te zappen</string>
<string name="draft_note">Concept notitie</string>
</resources>

Wyświetl plik

@ -293,6 +293,7 @@
<string name="block_only">Block</string>
<string name="bookmarks">Bookmarks</string>
<string name="drafts">Drafts</string>
<string name="private_bookmarks">Private Bookmarks</string>
<string name="public_bookmarks">Public Bookmarks</string>
<string name="add_to_private_bookmarks">Add to Private Bookmarks</string>

Wyświetl plik

@ -0,0 +1,55 @@
/**
* 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.benchmark
import androidx.benchmark.junit4.BenchmarkRule
import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.nio.charset.Charset
@RunWith(AndroidJUnit4::class)
class MetaTagsParserBenchmark {
private val html =
getInstrumentation().context.assets.open("github_amethyst.html")
.readBytes().toString(Charset.forName("utf-8"))
@get:Rule
val benchmarkRule = BenchmarkRule()
@Test
fun parseMetaTags() {
benchmarkRule.measureRepeated {
val metaOgTitle = MetaTagsParser.parse(html).find { it.attr("property") == "og:title" }
assertNotNull(metaOgTitle)
assertEquals(
"GitHub - vitorpamplona/amethyst: Nostr client for Android",
metaOgTitle!!.attr("content"),
)
}
}
}

Wyświetl plik

@ -68,6 +68,7 @@ class GiftWrapReceivingBenchmark {
markAsSensitive = true,
zapRaiserAmount = 10000,
geohash = null,
isDraft = true,
signer = sender,
) {
SealedGossipEvent.create(
@ -107,6 +108,7 @@ class GiftWrapReceivingBenchmark {
markAsSensitive = true,
zapRaiserAmount = 10000,
geohash = null,
isDraft = true,
signer = sender,
) {
SealedGossipEvent.create(

Wyświetl plik

@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.previews
package com.vitorpamplona.amethyst.commons.preview
import org.junit.Assert.assertEquals
import org.junit.Test

Wyświetl plik

@ -0,0 +1,88 @@
/**
* 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.commons.compose
import android.util.LruCache
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
@Composable
fun <K, V> produceCachedStateAsync(
cache: AsyncCachedState<K, V>,
key: K,
): State<V?> {
return produceState(initialValue = cache.cached(key), key1 = key) {
cache.update(key) {
value = it
}
}
}
@Composable
fun <K, V> produceCachedStateAsync(
cache: AsyncCachedState<K, V>,
key: String,
updateValue: K,
): State<V?> {
return produceState(initialValue = cache.cached(updateValue), key1 = key) {
cache.update(updateValue) {
value = it
}
}
}
interface AsyncCachedState<K, V> {
fun cached(k: K): V?
suspend fun update(
k: K,
onReady: (V?) -> Unit,
)
}
abstract class GenericBaseCacheAsync<K, V>(capacity: Int) : AsyncCachedState<K, V> {
private val cache = LruCache<K, V>(capacity)
override fun cached(k: K): V? {
return cache[k]
}
override suspend fun update(
k: K,
onReady: (V?) -> Unit,
) {
cache[k]?.let { onReady(it) }
compute(k) {
if (it != null) {
cache.put(k, it)
}
onReady(it)
}
}
abstract suspend fun compute(
key: K,
onReady: (V?) -> Unit,
)
}

Wyświetl plik

@ -18,26 +18,30 @@
* 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.service.previews
package com.vitorpamplona.amethyst.commons.preview
import kotlinx.collections.immutable.toImmutableMap
import java.lang.StringBuilder
internal data class MetaTag(private val attrs: Map<String, String>) {
data class MetaTag(private val attrs: Map<String, String>) {
/**
* Returns a value of an attribute specified by its name (case insensitive), or empty string if it doesn't exist.
*/
fun attr(name: String): String = attrs[name.lowercase()] ?: ""
}
// parse a partial HTML document and extract meta tags
internal object MetaTagsParser {
object MetaTagsParser {
private val NON_ATTR_NAME_CHARS = setOf(Char(0x0), '"', '\'', '>', '/')
private val NON_UNQUOTED_ATTR_VALUE_CHARS = setOf('"', '\'', '=', '>', '<', '`')
/**
* Lazily parse a partial HTML document and extract meta tags.
*/
fun parse(input: String): Sequence<MetaTag> =
sequence {
val s = TagScanner(input)
while (!s.exhausted()) {
val t = s.nextTag() ?: continue
if (t.name == "/head") {
if (t.name == "head" && t.isEnd) {
break
}
if (t.name == "meta") {
@ -47,61 +51,45 @@ internal object MetaTagsParser {
}
}
private data class RawTag(val name: String, val attrPart: String)
private data class RawTag(val isEnd: Boolean, val name: String, val attrPart: String)
private class TagScanner(private val input: String) {
var p = 0
private var p = 0
fun exhausted(): Boolean = p >= input.length
private fun peek(): Char = input[p]
private fun consume(): Char {
return input[p++]
}
private fun consume(): Char = input[p++]
private fun consumeChar(c: Char): Boolean {
if (this.peek() == c) {
private fun skipWhile(pred: (Char) -> Boolean) {
while (!this.exhausted() && pred(this.peek())) {
this.consume()
return true
}
return false
}
private fun skipSpaces() {
while (!this.exhausted() && this.peek().isWhitespace()) {
this.consume()
}
}
private fun skipUntil(c: Char) {
while (!this.exhausted() && this.peek() != c) {
this.consume()
}
}
private fun readWhile(pred: (Char) -> Boolean): String {
val sb = StringBuilder()
while (!this.exhausted() && pred(this.peek())) {
sb.append(this.consume())
}
return sb.toString()
this.skipWhile { it.isWhitespace() }
}
fun nextTag(): RawTag? {
skipUntil('<')
skipWhile { it != '<' }
consume()
// read tag name
val name = StringBuilder()
if (consumeChar('/')) {
name.append('/')
val isEnd = peek() == '/'
if (isEnd) {
consume()
}
val n = readWhile { !it.isWhitespace() && it != '>' }
skipSpaces()
val nameStart = p
skipWhile { !it.isWhitespace() && it != '>' }
val nameEnd = p
// read until end of tag
val attrsPart = StringBuilder()
// seek to start of attrs part
skipSpaces()
val attrsStart = p
// skip until end of tag
var quote: Char? = null
while (!exhausted()) {
val c = consume()
@ -124,13 +112,15 @@ internal object MetaTagsParser {
quote = null
}
}
attrsPart.append(c)
}
val attrsEnd = p - 1
if (!n.matches(Regex("""[0-9a-zA-Z]+"""))) {
val name = input.slice(nameStart..<nameEnd)
if (!name.matches(Regex("""[0-9a-zA-Z]+"""))) {
return null
}
return RawTag(name.append(n).toString().lowercase(), attrsPart.toString())
val attrsPart = input.slice(attrsStart..<attrsEnd)
return RawTag(isEnd, name.lowercase(), attrsPart)
}
}

Wyświetl plik

@ -15,7 +15,7 @@ coil = "2.6.0"
composeBom = "2024.03.00"
coreKtx = "1.12.0"
espressoCore = "3.5.1"
firebaseBom = "32.7.4"
firebaseBom = "32.8.0"
fragmentKtx = "1.6.2"
gms = "4.4.1"
jacksonModuleKotlin = "2.17.0"

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -87,7 +87,7 @@ class ChatMessageEvent(
) {
val tags = mutableListOf<Array<String>>()
to?.forEach { tags.add(arrayOf("p", it)) }
replyTos?.forEach { tags.add(arrayOf("e", it)) }
replyTos?.forEach { tags.add(arrayOf("e", it, "", "reply")) }
mentions?.forEach { tags.add(arrayOf("p", it, "", "mention")) }
zapReceiver?.forEach {
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))

Wyświetl plik

@ -34,22 +34,59 @@ class DeletionEvent(
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun deleteEvents() = tags.map { it[1] }
fun deleteEvents() = taggedEvents()
fun deleteAddresses() = taggedAddresses()
companion object {
const val KIND = 5
const val ALT = "Deletion event"
fun create(
deleteEvents: List<String>,
deleteEvents: List<Event>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DeletionEvent) -> Unit,
) {
val content = ""
val tags =
deleteEvents.map { arrayOf("e", it) }.plusElement(arrayOf("alt", ALT)).toTypedArray()
signer.sign(createdAt, KIND, tags, content, onReady)
val tags = mutableListOf<Array<String>>()
val kinds =
deleteEvents.mapTo(HashSet()) {
"${it.kind}"
}.map {
arrayOf("k", it)
}
tags.addAll(deleteEvents.map { arrayOf("e", it.id) })
tags.addAll(deleteEvents.mapNotNull { if (it is AddressableEvent) arrayOf("a", it.address().toTag()) else null })
tags.addAll(kinds)
tags.add(arrayOf("alt", ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
fun createForVersionOnly(
deleteEvents: List<Event>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DeletionEvent) -> Unit,
) {
val content = ""
val tags = mutableListOf<Array<String>>()
val kinds =
deleteEvents.mapTo(HashSet()) {
"${it.kind}"
}.map {
arrayOf("k", it)
}
tags.addAll(deleteEvents.map { arrayOf("e", it.id) })
tags.addAll(kinds)
tags.add(arrayOf("alt", ALT))
signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady)
}
}
}

Wyświetl plik

@ -21,8 +21,8 @@
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19Bech32
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@ -35,126 +35,175 @@ class DraftEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var decryptedContent: Map<HexKey, Event> = mapOf()
@Transient private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
@Transient private var citedNotesCache: Set<String>? = null
override fun isContentEncoded() = true
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)
fun isDeleted() = content == ""
val newStyleReplyTos = listOfNotNull(newStyleReply, newStyleRoot)
return if (newStyleReplyTos.isNotEmpty()) {
newStyleReplyTos
} else {
oldStylePositional
}
fun preCachedDraft(signer: NostrSigner): Event? {
return cachedInnerEvent[signer.pubKey]
}
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 preCachedDraft(pubKey: HexKey): Event? {
return cachedInnerEvent[pubKey]
}
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()
fun allCache() = cachedInnerEvent.values
val citations = findCitations()
return if (citations.isEmpty()) {
repliesTo + tagAddresses
} else {
repliesTo.filter { it !in citations }
}
fun addToCache(
pubKey: HexKey,
innerEvent: Event,
) {
cachedInnerEvent = cachedInnerEvent + Pair(pubKey, innerEvent)
}
fun cachedContentFor(): Event? {
return decryptedContent[dTag()]
}
fun plainContent(
fun cachedDraft(
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
decryptedContent[dTag()]?.let {
cachedInnerEvent[signer.pubKey]?.let {
onReady(it)
return
}
decrypt(signer) { draft ->
addToCache(signer.pubKey, draft)
signer.nip44Decrypt(content, signer.pubKey) { retVal ->
val event = runCatching { fromJson(retVal) }.getOrNull() ?: return@nip44Decrypt
decryptedContent = decryptedContent + Pair(dTag(), event)
onReady(draft)
}
}
onReady(event)
private fun decrypt(
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
try {
plainContent(signer) { onReady(fromJson(it)) }
} catch (e: Exception) {
// Log.e("UnwrapError", "Couldn't Decrypt the content", e)
}
}
private fun plainContent(
signer: NostrSigner,
onReady: (String) -> Unit,
) {
if (content.isEmpty()) return
signer.nip44Decrypt(content, pubKey, onReady)
}
fun createDeletedEvent(
signer: NostrSigner,
onReady: (DraftEvent) -> Unit,
) {
signer.sign<DraftEvent>(createdAt, KIND, tags, "") {
onReady(it)
}
}
companion object {
const val KIND = 31234
fun createAddressTag(
pubKey: HexKey,
dTag: String,
): String {
return ATag(KIND, pubKey, dTag, null).toTag()
}
fun create(
dTag: String,
originalNote: EventInterface,
originalNote: LiveActivitiesChatMessageEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag())) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) }
create(dTag, originalNote, emptyList(), signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: ChannelMessageEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
originalNote.channel()?.let { tags.add(arrayOf("e", it)) }
create(dTag, originalNote, tags, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: GitReplyEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tags = mutableListOf<Array<String>>()
originalNote.repository()?.let { tags.add(arrayOf("a", it.toTag())) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) }
create(dTag, originalNote, tags, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: PollNoteEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tagsWithMarkers =
originalNote.tags().filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
}
fun create(
dTag: String,
originalNote: TextNoteEvent,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
onReady: (DraftEvent) -> Unit,
) {
val tagsWithMarkers =
originalNote.tags().filter {
it.size > 3 && (it[0] == "e" || it[0] == "a") && (it[3] == "root" || it[3] == "reply")
}
create(dTag, originalNote, tagsWithMarkers, signer, createdAt, onReady)
}
fun create(
dTag: String,
innerEvent: Event,
anchorTagArray: List<Array<String>> = emptyList(),
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" })
tags.add(arrayOf("k", "${innerEvent.kind}"))
signer.nip44Encrypt(originalNote.toJson(), signer.pubKey) { encryptedContent ->
signer.sign(createdAt, KIND, tags.toTypedArray(), encryptedContent, onReady)
if (anchorTagArray.isNotEmpty()) {
tags.addAll(anchorTagArray)
}
signer.nip44Encrypt(innerEvent.toJson(), signer.pubKey) { encryptedContent ->
signer.sign<DraftEvent>(createdAt, KIND, tags.toTypedArray(), encryptedContent) {
it.addToCache(signer.pubKey, innerEvent)
onReady(it)
}
}
}
}

Wyświetl plik

@ -97,6 +97,8 @@ open class Event(
override fun firstTaggedUrl() = tags.firstOrNull { it.size > 1 && it[0] == "r" }?.let { it[1] }
override fun firstTaggedK() = tags.firstOrNull { it.size > 1 && it[0] == "k" }?.let { it[1].toIntOrNull() }
override fun firstTaggedAddress() =
tags
.firstOrNull { it.size > 1 && it[0] == "a" }

Wyświetl plik

@ -133,6 +133,8 @@ interface EventInterface {
fun firstTaggedUrl(): String?
fun firstTaggedK(): Int?
fun taggedEmojis(): List<EmojiUrl>
fun matchTag1With(text: String): Boolean

Wyświetl plik

@ -146,7 +146,7 @@ class PrivateDmEvent(
val tags = mutableListOf<Array<String>>()
publishedRecipientPubKey?.let { tags.add(arrayOf("p", publishedRecipientPubKey)) }
replyTos?.forEach { tags.add(arrayOf("e", it)) }
replyTos?.forEach { tags.add(arrayOf("e", it, "", "reply")) }
mentions?.forEach { tags.add(arrayOf("p", it)) }
zapReceiver?.forEach {
tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString()))