Porównaj commity

...

74 Commity

Autor SHA1 Wiadomość Data
Tony Giorgio e7fc6e4efe
Merge 08f1b43908 into 8125a7dabb 2024-04-02 07:07:00 +02: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
Vitor Pamplona 539433014e Fixes Notification for Follows now showing Zaps 2024-03-26 21:05:24 -04:00
Vitor Pamplona d3f54a7082 Removes the Draft dependency for signer implementations 2024-03-26 16:56:58 -04:00
Vitor Pamplona d3a0ae743a
Merge pull request #749 from greenart7c3/main
save a draft while you are typing the post
2024-03-26 15:24:59 -04:00
Vitor Pamplona 6690d5391c Fixes controller comparison for keep playing 2024-03-26 15:06:55 -04:00
Vitor Pamplona d61d684a27
Merge pull request #818 from jiftechnify/url-preview
Fix garbled URL preview for non UTF-8 HTML, with optimization
2024-03-26 13:40:15 -04:00
jiftechnify 4f84fad0cd
remove jsoup version 2024-03-27 01:03:29 +09:00
jiftechnify a71ce69cab
support tags in quoted attribute value 2024-03-27 00:47:35 +09:00
greenart7c3 6e6fa66c53
Merge branch 'main' into main 2024-03-25 17:34:22 -03:00
jiftechnify bffb9f3778
remove jsoup from dependencies 2024-03-26 02:42:15 +09:00
jiftechnify e11961695f
parse HTML as little as possible 2024-03-26 02:31:06 +09:00
jiftechnify 042579ddfb
remove unnecessary items from mata-tag canditates for getting URL info 2024-03-25 23:53:47 +09:00
jiftechnify d0aa7430ca
optimize HTML charset detection 2024-03-25 23:53:47 +09:00
jiftechnify 3434c31487
fix garbled URL preview for non-UTF-8 HTML 2024-03-25 23:53:43 +09:00
greenart7c3 ed4d867622 save draft when toogling nip4 and nip 44 2024-03-25 07:48:30 -03:00
greenart7c3 27db2b91ab save draft when changing options from polls and classifieds 2024-03-25 07:32:31 -03:00
greenart7c3 a2316b6ed0
Merge branch 'main' into main 2024-03-25 07:08:08 -03:00
greenart7c3 62a114b981
Merge branch 'main' into main 2024-03-22 08:48:47 -03:00
greenart7c3 8d7a3f4d5e
Merge branch 'main' into main 2024-03-20 15:13:33 -03:00
greenart7c3 c087042f7d Merge branch 'main' into main 2024-03-20 15:10:32 -03:00
greenart7c3 644d2fc2bb add generic draft event to default permissions 2024-03-20 14:56:29 -03:00
greenart7c3 ea33cc77ed add edit draft in the dropdown menu and the long press popup 2024-03-20 14:50:53 -03:00
greenart7c3 090b643f43 add edit draft in the dropdown menu and the long press popup 2024-03-20 14:45:45 -03:00
greenart7c3 220ce75f19 add validations to draft notes 2024-03-20 10:25:41 -03:00
greenart7c3 bc180ae210 fix draft delete not working 2024-03-20 09:31:31 -03:00
greenart7c3 5910ef199f fix build after merge 2024-03-20 07:22:55 -03:00
greenart7c3 499939ed68
Merge branch 'main' into main 2024-03-20 07:06:24 -03:00
greenart7c3 940fa2ee8d add debouncer 2024-03-18 17:47:15 -03:00
greenart7c3 f6e5af3e98 support for dms, streams and communities 2024-03-18 16:18:12 -03:00
greenart7c3 8b3e3e7af8 support Classifieds 2024-03-18 14:51:50 -03:00
greenart7c3 84faa7557e add support for polls 2024-03-18 14:37:51 -03:00
greenart7c3 f7ab925b1d fix delete on close 2024-03-18 14:21:58 -03:00
greenart7c3 204eaa4606 fix crash when loading draft from channels or lives 2024-03-18 11:16:13 -03:00
greenart7c3 1c249eed20 load post from draft 2024-03-18 10:56:36 -03:00
greenart7c3 3cc32ecd9a create a loadFromDraft method 2024-03-18 09:09:00 -03:00
greenart7c3 0a20d5484b show drafts as soon as its created 2024-03-18 08:39:55 -03:00
greenart7c3 6e4f1269dd add todo 2024-03-18 07:43:19 -03:00
greenart7c3 eba0837e52 fix draftnotes filter 2024-03-18 07:20:30 -03:00
greenart7c3 d682518ddb
Merge branch 'main' into main 2024-03-18 07:07:32 -03:00
greenart7c3 f949d5624e add draft support for public chat screen 2024-03-15 13:35:54 -03:00
greenart7c3 2bc2890d08 add draft support for other event kinds 2024-03-15 12:35:14 -03:00
greenart7c3 f3f8bc1b65 show draft in the simplified view 2024-03-15 10:45:53 -03:00
greenart7c3 99e9514d6c remove drafts from shared prefs 2024-03-15 10:38:22 -03:00
greenart7c3 8ade5b7e5f
Merge branch 'main' into main 2024-03-15 09:18:38 -03:00
greenart7c3 e292affbe6 add draft in the home feed 2024-03-15 09:08:35 -03:00
greenart7c3 fa5d992010 add draftevent class 2024-03-13 13:24:22 -03:00
greenart7c3 0d47e8b823
Merge branch 'main' into main 2024-03-13 11:24:12 -03:00
greenart7c3 91b0d5b7fc
Merge branch 'main' into main 2024-03-13 07:17:52 -03:00
greenart7c3 4938ba03a6
Merge branch 'main' into main 2024-03-08 12:35:23 -03:00
greenart7c3 4d2c17cd1c fix draft on nip94 2024-02-28 11:16:19 -03:00
greenart7c3 53987336c0
Merge branch 'main' into main 2024-02-28 10:59:39 -03:00
greenart7c3 cdd620987b implement reply draft 2024-01-24 09:55:31 -03:00
greenart7c3 2c086f76e2 open the post screen after editing the text 2024-01-24 08:40:55 -03:00
greenart7c3 99965ecd2d add an edit draft in the drawer 2024-01-24 08:22:35 -03:00
greenart7c3 ba7c59fdd5 draft was not saving in some places 2024-01-24 08:22:11 -03:00
greenart7c3 76a93f84c3 fix default value for the draft note 2024-01-24 08:21:04 -03:00
greenart7c3 26a1624399 save a draft while you are typing the post 2024-01-22 09:02:44 -03:00
Tony Giorgio 08f1b43908
Change release to use personal github token 2023-06-08 11:07:08 -05:00
75 zmienionych plików z 11263 dodań i 764 usunięć

Wyświetl plik

@ -84,7 +84,7 @@ jobs:
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
@ -96,7 +96,7 @@ jobs:
id: upload-release-asset-play-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-universal-release-unsigned-signed.apk
@ -107,7 +107,7 @@ jobs:
id: upload-release-asset-play-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86-release-unsigned-signed.apk
@ -118,7 +118,7 @@ jobs:
id: upload-release-asset-play-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/play/release/app-play-x86_64-release-unsigned-signed.apk
@ -152,7 +152,7 @@ jobs:
id: upload-release-asset-fdroid-universal-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-universal-release-unsigned-signed.apk
@ -163,7 +163,7 @@ jobs:
id: upload-release-asset-fdroid-x86-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86-release-unsigned-signed.apk
@ -174,7 +174,7 @@ jobs:
id: upload-release-asset-fdroid-x86-64-apk
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/apk/fdroid/release/app-fdroid-x86_64-release-unsigned-signed.apk
@ -210,7 +210,7 @@ jobs:
id: upload-release-asset-play-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/playRelease/app-play-release.aab
@ -222,7 +222,7 @@ jobs:
id: upload-release-asset-fdroid-aab
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.PERSONAL_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: app/build/outputs/bundle/fdroidRelease/app-fdroid-release.aab

Wyświetl plik

@ -205,9 +205,6 @@ dependencies {
// Websockets API
implementation libs.okhttp
// HTML Parsing for Link Preview
implementation libs.jsoup
// Encrypted Key Storage
implementation libs.androidx.security.crypto.ktx

Wyświetl plik

@ -56,10 +56,12 @@ import com.vitorpamplona.quartz.events.ClassifiedsEvent
import com.vitorpamplona.quartz.events.Contact
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
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
@ -844,17 +846,18 @@ class Account(
}
}
suspend fun delete(note: Note) {
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)
}
@ -929,6 +932,7 @@ class Account(
fun timestamp(note: Note) {
if (!isWriteable()) return
if (note.isDraft()) return
val id = note.event?.id() ?: note.idHex
@ -1318,6 +1322,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<Event>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1345,14 +1350,26 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
if (draftTag != null) {
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)
LocalCache.justConsume(it, null)
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
@ -1373,6 +1390,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1396,26 +1414,52 @@ class Account(
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
} else {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
}
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>?,
@ -1430,6 +1474,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1453,20 +1498,32 @@ class Account(
nip94attachments = nip94attachments,
forkedFrom = forkedFrom,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent, relayList = relayList)
LocalCache.justConsume(draftEvent, null)
}
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
} else {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
// broadcast replied notes
replyingTo?.let {
LocalCache.getNoteIfExists(replyingTo)?.event?.let {
Client.send(it, relayList = relayList)
}
}
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
@ -1510,6 +1567,7 @@ class Account(
relayList: List<Relay>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1533,15 +1591,27 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
isDraft = draftTag != null,
) {
Client.send(it, relayList = relayList)
LocalCache.justConsume(it, null)
if (draftTag != null) {
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)
LocalCache.justConsume(it, null)
// Rebroadcast replies and tags to the current relay set
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
// Rebroadcast replies and tags to the current relay set
replyTo?.forEach { it.event?.let { Client.send(it, relayList = relayList) } }
addresses?.forEach {
LocalCache.getAddressableNoteIfExists(it.toTag())?.event?.let {
Client.send(it, relayList = relayList)
}
}
}
}
@ -1557,6 +1627,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1574,9 +1645,21 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it)
LocalCache.justConsume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
@ -1590,6 +1673,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1608,9 +1692,21 @@ class Account(
geohash = geohash,
nip94attachments = nip94attachments,
signer = signer,
isDraft = draftTag != null,
) {
Client.send(it)
LocalCache.justConsume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
LocalCache.justConsume(it, null)
}
}
}
@ -1624,6 +1720,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
sendPrivateMessage(
message,
@ -1635,6 +1732,7 @@ class Account(
zapRaiserAmount,
geohash,
nip94attachments,
draftTag,
)
}
@ -1648,6 +1746,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String?,
) {
if (!isWriteable()) return
@ -1667,9 +1766,21 @@ class Account(
nip94attachments = nip94attachments,
signer = signer,
advertiseNip18 = false,
isDraft = draftTag != null,
) {
Client.send(it)
LocalCache.consume(it, null)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
Client.send(it)
LocalCache.consume(it, null)
}
}
}
@ -1684,6 +1795,7 @@ class Account(
zapRaiserAmount: Long? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
draftTag: String? = null,
) {
if (!isWriteable()) return
@ -1701,9 +1813,21 @@ class Account(
zapRaiserAmount = zapRaiserAmount,
geohash = geohash,
nip94attachments = nip94attachments,
draftTag = draftTag,
signer = signer,
) {
broadcastPrivately(it)
if (draftTag != null) {
if (message.isBlank()) {
deleteDraft(draftTag)
} else {
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
Client.send(draftEvent)
LocalCache.justConsume(draftEvent, null)
}
}
} else {
broadcastPrivately(it)
}
}
}
@ -1785,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)
}
@ -1851,6 +1975,7 @@ class Account(
isPrivate: Boolean,
) {
if (!isWriteable()) return
if (note.isDraft()) return
if (note is AddressableNote) {
BookmarkListEvent.addReplaceable(
@ -2217,7 +2342,12 @@ 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()) {

Wyświetl plik

@ -62,6 +62,7 @@ import com.vitorpamplona.quartz.events.CommunityListEvent
import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DeletionEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
@ -104,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
@ -128,7 +130,6 @@ object LocalCache {
val users = LargeCache<HexKey, User>()
val notes = LargeCache<HexKey, Note>()
val addressables = LargeCache<String, AddressableNote>()
val channels = LargeCache<HexKey, Channel>()
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
@ -350,7 +351,7 @@ object LocalCache {
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
@ -433,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()}")
@ -477,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)
@ -512,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)
@ -521,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,
@ -541,7 +588,7 @@ object LocalCache {
return
}
val replyTo = event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
val replyTo = computeReplyTo(event)
note.loadEvent(event, author, replyTo)
@ -762,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)
@ -783,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)
@ -843,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)
@ -857,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)
}
@ -894,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)
@ -918,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)
}
}
@ -977,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)
@ -999,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)
@ -1023,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) }
@ -1042,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)
@ -1072,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)
@ -1173,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)
@ -1216,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)
@ -1250,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)
@ -1279,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)
@ -1483,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)
@ -2013,6 +2094,118 @@ object LocalCache {
}
}
private fun consume(
event: DraftEvent,
relay: 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(
event: Event,
relay: Relay?,
@ -2050,6 +2243,7 @@ object LocalCache {
}
is ContactListEvent -> consume(event)
is DeletionEvent -> consume(event)
is DraftEvent -> consume(event, relay)
is EmojiPackEvent -> consume(event, relay)
is EmojiPackSelectionEvent -> consume(event, relay)
is SealedGossipEvent -> consume(event, relay)

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,6 +193,8 @@ open class Note(val idHex: String) {
open fun createdAt() = event?.createdAt()
fun isDraft() = event is DraftEvent
fun loadEvent(
event: Event,
author: User,
@ -928,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

@ -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

@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.events.CalendarRSVPEvent
import com.vitorpamplona.quartz.events.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.DraftEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface
@ -144,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()]
@ -262,22 +263,53 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
when (event) {
is DraftEvent -> {
// Avoid decrypting over and over again if the event already exist.
event.cachedGift(account.signer) { this.consume(it, relay) }
}
if (!event.isDeleted()) {
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
if (event is SealedGossipEvent) {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
// decrypts
event.cachedDraft(account.signer) {}
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
} else {
LocalCache.justConsume(event, relay)
LocalCache.justConsume(event, relay)
}
}
is GiftWrapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGift(account.signer) { this.consume(it, relay) }
}
is SealedGossipEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.cachedGossip(account.signer) { LocalCache.justConsume(it, relay) }
}
is LnZapEvent -> {
// Avoid decrypting over and over again if the event already exist.
val note = LocalCache.getNoteIfExists(event.id)
if (note != null && relay.brief in note.relays) return
event.zapRequest?.let {
if (it.isPrivateZap()) {
it.decryptPrivateZap(account.signer) {}
}
}
}
else -> {
LocalCache.justConsume(event, relay)
}
}
}
}

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

@ -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
@ -27,60 +29,39 @@ import kotlinx.coroutines.withContext
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import okio.BufferedSource
import okio.ByteString.Companion.decodeHex
import okio.Options
import java.nio.charset.Charset
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
private const val ATTRIBUTE_VALUE_NAME = "name"
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
// for <meta itemprop=... to get title
private val META_X_TITLE =
arrayOf(
"og:title",
"\"og:title\"",
"'og:title'",
"name",
"\"name\"",
"'name'",
"twitter:title",
"\"twitter:title\"",
"'twitter:title'",
"title",
"\"title\"",
"'title'",
)
// for <meta itemprop=... to get description
private val META_X_DESCRIPTION =
arrayOf(
"og:description",
"\"og:description\"",
"'og:description'",
"description",
"\"description\"",
"'description'",
"twitter:description",
"\"twitter:description\"",
"'twitter:description'",
"description",
"\"description\"",
"'description'",
)
// for <meta itemprop=... to get image
private val META_X_IMAGE =
arrayOf(
"og:image",
"\"og:image\"",
"'og:image'",
"image",
"\"image\"",
"'image'",
"twitter:image",
"\"twitter:image\"",
"'twitter:image'",
"image",
)
private const val CONTENT = "content"
@ -95,14 +76,12 @@ suspend fun getDocument(
checkNotInMainThread()
if (it.isSuccessful) {
val mimeType =
it.headers.get("Content-Type")?.toMediaType()
it.headers["Content-Type"]?.toMediaType()
?: throw IllegalArgumentException(
"Website returned unknown mimetype: ${it.headers.get("Content-Type")}",
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
)
if (mimeType.type == "text" && mimeType.subtype == "html") {
val document = Jsoup.parse(it.body.string())
parseHtml(url, document, mimeType)
parseHtml(url, it.body.source(), mimeType)
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
} else if (mimeType.type == "video") {
@ -120,65 +99,141 @@ suspend fun getDocument(
suspend fun parseHtml(
url: String,
document: Document,
source: BufferedSource,
type: MediaType,
): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
// sniff charset from Content-Type header or BOM
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
if (sniffedCharset != null) {
val metaTags = MetaTagsParser.parse(source.readByteArray().toString(sniffedCharset))
return@withContext extractUrlInfo(url, metaTags, type)
}
var title: String = ""
var description: String = ""
var image: String = ""
// if sniffing was failed, detect charset from content
val bodyBytes = source.readByteArray()
val charset = detectCharset(bodyBytes)
val metaTags = MetaTagsParser.parse(bodyBytes.toString(charset))
return@withContext extractUrlInfo(url, metaTags, type)
}
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
// taken from okhttp
private val UNICODE_BOMS =
Options.of(
// UTF-8
"efbbbf".decodeHex(),
// UTF-16BE
"feff".decodeHex(),
// UTF-16LE
"fffe".decodeHex(),
// UTF-32BE
"0000ffff".decodeHex(),
// UTF-32LE
"ffff0000".decodeHex(),
)
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
private fun BufferedSource.readBomAsCharset(): Charset? {
return when (select(UNICODE_BOMS)) {
0 -> Charsets.UTF_8
1 -> Charsets.UTF_16BE
2 -> Charsets.UTF_16LE
3 -> Charsets.UTF_32BE
4 -> Charsets.UTF_32LE
-1 -> null
else -> throw AssertionError()
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image, type)
private fun detectCharset(bodyBytes: ByteArray): Charset {
// try to detect charset from meta tags parsed from first 1024 bytes of body
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
val metaTags = MetaTagsParser.parse(firstPart)
metaTags.forEach { meta ->
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
if (charsetAttr.isNotEmpty()) {
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
return it
}
}
return@withContext UrlInfoItem(url, title, description, image, type)
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
RE_CONTENT_TYPE_CHARSET.find(meta.attr(CONTENT))
?.let {
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
}?.let {
return it
}
}
}
// defaults to UTF-8
return Charset.forName("utf-8")
}
private fun extractUrlInfo(
url: String,
metaTags: Sequence<MetaTag>,
type: MediaType,
): UrlInfoItem {
var title: String = ""
var description: String = ""
var image: String = ""
metaTags.forEach {
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_X_TITLE ->
if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_X_DESCRIPTION ->
if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_X_IMAGE ->
if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return UrlInfoItem(url, title, description, image, type)
}
}
return UrlInfoItem(url, title, description, image, type)
}

Wyświetl plik

@ -45,7 +45,9 @@ fun NewPollOption(
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
onClick = { pollViewModel.pollOptions.remove(optionIndex) },
onClick = {
pollViewModel.removePollOption(optionIndex)
},
) {
Icon(
imageVector = Icons.Default.Delete,
@ -57,7 +59,9 @@ fun NewPollOption(
OutlinedTextField(
modifier = Modifier.weight(1F),
value = pollViewModel.pollOptions[optionIndex] ?: "",
onValueChange = { pollViewModel.pollOptions[optionIndex] = it },
onValueChange = {
pollViewModel.updatePollOption(optionIndex, it)
},
label = {
Text(
text = stringResource(R.string.poll_option_index).format(optionIndex + 1),

Wyświetl plik

@ -171,13 +171,17 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.lang.Math.round
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, FlowPreview::class)
@Composable
fun NewPostView(
onClose: () -> Unit,
@ -185,6 +189,7 @@ fun NewPostView(
quote: Note? = null,
fork: Note? = null,
version: Note? = null,
draft: Note? = null,
enableMessageInterface: Boolean = false,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -199,10 +204,21 @@ fun NewPostView(
var showRelaysDialog by remember { mutableStateOf(false) }
var relayList = remember { accountViewModel.account.activeWriteRelays().toImmutableList() }
LaunchedEffect(Unit) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version)
LaunchedEffect(key1 = postViewModel.draftTag) {
launch(Dispatchers.IO) {
postViewModel.draftTextChanges
.receiveAsFlow()
.debounce(1000)
.collectLatest {
postViewModel.sendDraft(relayList = relayList)
}
}
}
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
postViewModel.load(accountViewModel, baseReplyTo, quote, fork, version, draft)
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) { Toast.makeText(context, error, Toast.LENGTH_SHORT).show() }
}
@ -351,7 +367,7 @@ fun NewPostView(
}
}
if (enableMessageInterface) {
if (postViewModel.wantsDirectMessage) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
@ -581,7 +597,7 @@ private fun BottomRowActions(postViewModel: NewPostViewModel) {
}
MarkAsSensitive(postViewModel) {
postViewModel.wantsToMarkAsSensitive = !postViewModel.wantsToMarkAsSensitive
postViewModel.toggleMarkAsSensitive()
}
AddGeoHash(postViewModel) {
@ -827,7 +843,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.title,
onValueChange = { postViewModel.title = it },
onValueChange = {
postViewModel.updateTitle(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
@ -863,13 +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.updatePrice(it)
},
placeholder = {
Text(
@ -934,7 +946,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
TextSpinner(
placeholder = conditionTypes.filter { it.first == postViewModel.condition }.first().second,
options = conditionOptions,
onSelect = { postViewModel.condition = conditionTypes[it].first },
onSelect = {
postViewModel.updateCondition(conditionTypes[it].first)
},
modifier =
Modifier
.weight(1f)
@ -998,7 +1012,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
categoryTypes.filter { it.second == postViewModel.category.text }.firstOrNull()?.second
?: "",
options = categoryOptions,
onSelect = { postViewModel.category = TextFieldValue(categoryTypes[it].second) },
onSelect = {
postViewModel.updateCategory(TextFieldValue(categoryTypes[it].second))
},
modifier =
Modifier
.weight(1f)
@ -1033,7 +1049,9 @@ fun SellProduct(postViewModel: NewPostViewModel) {
MyTextField(
value = postViewModel.locationText,
onValueChange = { postViewModel.locationText = it },
onValueChange = {
postViewModel.updateLocation(it)
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(

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
@ -69,10 +72,12 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import java.util.UUID
enum class UserSuggestionAnchor {
MAIN_MESSAGE,
@ -82,6 +87,8 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var draftTag: String by mutableStateOf(UUID.randomUUID().toString())
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@ -164,6 +171,8 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
val draftTextChanges = Channel<String>(Channel.CONFLATED)
fun lnAddress(): String? {
return account?.userProfile()?.info?.lnAddress()
}
@ -182,134 +191,272 @@ open class NewPostViewModel() : ViewModel() {
quote: Note?,
fork: Note?,
version: Note?,
draft: Note?,
) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
} else {
this.eTags = listOf(replyNote)
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 ->
if (replyNote.event is BaseTextNoteEvent) {
this.eTags = (replyNote.replyTo ?: emptyList()).plus(replyNote)
} else {
this.eTags = listOf(replyNote)
}
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions =
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
if (replyNote.event !is CommunityDefinitionEvent) {
replyNote.author?.let { replyUser ->
val currentMentions =
(replyNote.event as? TextNoteEvent)?.mentions()?.map { LocalCache.getOrCreateUser(it) }
?: emptyList()
if (currentMentions.contains(replyUser)) {
this.pTags = currentMentions
} else {
this.pTags = currentMentions.plus(replyUser)
if (currentMentions.contains(replyUser)) {
this.pTags = currentMentions
} else {
this.pTags = currentMentions.plus(replyUser)
}
}
}
}
}
?: run {
eTags = null
pTags = null
?: run {
eTags = null
pTags = null
}
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
} ?: run {
forkedFromNote = null
}
if (!forwardZapTo.items.isEmpty()) {
wantsForwardZapTo = true
}
}
}
private fun loadFromDraft(
draft: Note,
accountViewModel: AccountViewModel,
) {
Log.d("draft", draft.event!!.toJson())
val draftEvent = draft.event ?: return
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
wantsForwardZapTo = false
wantsToMarkAsSensitive = false
wantsToAddGeoHash = false
wantsZapraiser = false
zapRaiserAmount = null
val localfowardZapTo = draftEvent.tags().filter { it.size > 1 && it[0] == "zap" }
forwardZapTo = Split()
localfowardZapTo.forEach {
val user = LocalCache.getOrCreateUser(it[1])
val value = it.last().toFloatOrNull() ?: 0f
forwardZapTo.addItem(user, value)
}
forwardZapToEditting = TextFieldValue("")
wantsForwardZapTo = localfowardZapTo.isNotEmpty()
quote?.let {
message = TextFieldValue(message.text + "\nnostr:${it.toNEvent()}")
urlPreview = findUrlInMessage()
it.author?.let { quotedUser ->
if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == quotedUser.pubkeyHex }) {
forwardZapTo.addItem(quotedUser)
}
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) {
forwardZapTo.addItem(accountViewModel.userProfile())
}
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == quotedUser.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.9f)
}
}
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) {
zapRaiserAmount = zapraiser.first()[1].toLongOrNull() ?: 0
}
fork?.let {
message = TextFieldValue(version?.event?.content() ?: it.event?.content() ?: "")
urlPreview = findUrlInMessage()
it.event?.isSensitive()?.let {
if (it) wantsToMarkAsSensitive = true
eTags =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull {
val note = LocalCache.checkGetOrCreateNote(it[1])
note
}
it.event?.zapraiserAmount()?.let {
zapRaiserAmount = it
}
it.event?.zapSplitSetup()?.let {
val totalWeight = it.sumOf { if (it.isLnAddress) 0.0 else it.weight }
it.forEach {
if (!it.isLnAddress) {
forwardZapTo.addItem(LocalCache.getOrCreateUser(it.lnAddressOrPubKeyHex), (it.weight / totalWeight).toFloat())
}
if (draftEvent !is PrivateDmEvent && draftEvent !is ChatMessageEvent) {
pTags =
draftEvent.tags().filter { it.size > 1 && it[0] == "p" }.map {
LocalCache.getOrCreateUser(it[1])
}
}
// Only adds if it is not already set up.
if (forwardZapTo.items.isEmpty()) {
it.author?.let { forkedAuthor ->
if (forkedAuthor.pubkeyHex != accountViewModel.userProfile().pubkeyHex) {
if (forwardZapTo.items.none { it.key.pubkeyHex == forkedAuthor.pubkeyHex }) forwardZapTo.addItem(forkedAuthor)
if (forwardZapTo.items.none { it.key.pubkeyHex == accountViewModel.userProfile().pubkeyHex }) forwardZapTo.addItem(accountViewModel.userProfile())
val pos = forwardZapTo.items.indexOfFirst { it.key.pubkeyHex == forkedAuthor.pubkeyHex }
forwardZapTo.updatePercentage(pos, 0.8f)
}
}
}
it.author?.let {
if (this.pTags == null) {
this.pTags = listOf(it)
} else if (this.pTags?.contains(it) != true) {
this.pTags = listOf(it) + (this.pTags ?: emptyList())
}
}
forkedFromNote = it
} ?: run {
forkedFromNote = null
}
if (!forwardZapTo.items.isEmpty()) {
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 =
draftEvent.tags().filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) == "reply" }.map {
LocalCache.checkGetOrCreateNote(it[1])
}.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
if (forwardZapTo.items.isNotEmpty()) {
wantsForwardZapTo = true
}
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 = 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()
} else if (it[0] == "value_minimum") {
valueMinimum = it[1].toInt()
}
}
wantsProduct = draftEvent.kind() == 30402
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 == 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 (draftEvent is PrivateDmEvent) {
val recepientNpub = draftEvent.verifiedRecipientPubKey()?.let { Hex.decode(it).toNpub() }
toUsers = TextFieldValue("@$recepientNpub")
TextFieldValue(draftEvent.cachedContentFor(accountViewModel.account.signer) ?: "")
} else {
TextFieldValue(draftEvent.content())
}
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) {
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, null)
accountViewModel?.deleteDraft(draftTag)
cancel()
}
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
fun sendDraft(relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList, draftTag)
}
}
private suspend fun innerSendPost(
relayList: List<Relay>? = null,
localDraft: String?,
) {
if (accountViewModel == null) {
cancel()
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!!)
@ -363,6 +510,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendChannelMessage(
@ -375,6 +523,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is PrivateDmEvent) {
@ -388,6 +537,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (originalNote?.event is ChatMessageEvent) {
val receivers =
@ -409,6 +559,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else if (!dmUsers.isNullOrEmpty()) {
if (nip24 || dmUsers.size > 1) {
@ -423,6 +574,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
account?.sendPrivateMessage(
@ -435,6 +587,7 @@ open class NewPostViewModel() : ViewModel() {
zapRaiserAmount = localZapRaiserAmount,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
} else if (originalNote?.event is GitIssueEvent) {
@ -475,24 +628,26 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} 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,
)
} else if (wantsProduct) {
account?.sendClassifieds(
@ -511,6 +666,7 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
} else {
// adds markers
@ -547,11 +703,10 @@ open class NewPostViewModel() : ViewModel() {
relayList = relayList,
geohash = geoHash,
nip94attachments = usedAttachments,
draftTag = localDraft,
)
}
}
cancel()
}
fun upload(
@ -652,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
@ -664,9 +822,17 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionAnchor = null
userSuggestionsMainMessage = null
draftTag = UUID.randomUUID().toString()
NostrSearchEventOrUserDataSource.clear()
}
fun deleteDraft() {
viewModelScope.launch(Dispatchers.IO) {
accountViewModel?.deleteDraft(draftTag)
}
}
open fun findUrlInMessage(): String? {
return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String ->
@ -679,6 +845,10 @@ open class NewPostViewModel() : ViewModel() {
pTags = pTags?.filter { it != userToRemove }
}
private fun saveDraft() {
draftTextChanges.trySend("")
}
open fun updateMessage(it: TextFieldValue) {
message = it
urlPreview = findUrlInMessage()
@ -701,6 +871,8 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun updateToUsers(it: TextFieldValue) {
@ -724,10 +896,12 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun updateSubject(it: TextFieldValue) {
subject = it
saveDraft()
}
open fun updateZapForwardTo(it: TextFieldValue) {
@ -754,6 +928,7 @@ open class NewPostViewModel() : ViewModel() {
userSuggestions = emptyList()
}
}
saveDraft()
}
open fun autocompleteWithUser(item: User) {
@ -799,6 +974,8 @@ open class NewPostViewModel() : ViewModel() {
userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
saveDraft()
}
private fun newStateMapPollOptions(): SnapshotStateMap<Int, String> {
@ -869,6 +1046,7 @@ open class NewPostViewModel() : ViewModel() {
message = message.insertUrlAtCursor(imageUrl)
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
@ -913,6 +1091,7 @@ open class NewPostViewModel() : ViewModel() {
}
urlPreview = findUrlInMessage()
saveDraft()
}
},
onError = {
@ -933,6 +1112,7 @@ open class NewPostViewModel() : ViewModel() {
locUtil?.let {
location =
it.locationStateFlow.mapLatest { it.toGeoHash(GeohashPrecision.KM_5_X_5.digits).toString() }
saveDraft()
}
viewModelScope.launch(Dispatchers.IO) { locUtil?.start() }
}
@ -957,6 +1137,9 @@ open class NewPostViewModel() : ViewModel() {
} else {
nip24 = !nip24
}
if (message.text.isNotBlank()) {
saveDraft()
}
}
fun updateMinZapAmountForPoll(textMin: String) {
@ -976,6 +1159,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
saveDraft()
}
fun updateMaxZapAmountForPoll(textMax: String) {
@ -995,6 +1179,7 @@ open class NewPostViewModel() : ViewModel() {
}
checkMinMax()
saveDraft()
}
fun checkMinMax() {
@ -1013,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

@ -408,7 +408,7 @@ fun GetVideoController(
val keepPlaying =
remember(videoUri) {
mutableStateOf<Boolean>(
keepPlayingMutex != null && controller == keepPlayingMutex,
keepPlayingMutex != null && controller.value == keepPlayingMutex,
)
}
@ -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

@ -94,9 +94,9 @@ fun ZapRaiserRequest(
onValueChange = {
runCatching {
if (it.isEmpty()) {
newPostViewModel.zapRaiserAmount = null
newPostViewModel.updateZapRaiserAmount(null)
} else {
newPostViewModel.zapRaiserAmount = it.toLongOrNull()
newPostViewModel.updateZapRaiserAmount(it.toLongOrNull())
}
}
},

Wyświetl plik

@ -0,0 +1,56 @@
/**
* 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.dal
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

@ -88,7 +88,23 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
filterParams: FilterByListParams,
): Boolean {
val loggedInUserHex = account.userProfile().pubkeyHex
val loggedInUser = account.userProfile()
val noteEvent = it.event
val notifAuthor =
if (noteEvent is LnZapEvent) {
val zapRequest = noteEvent.zapRequest
if (zapRequest != null) {
if (noteEvent.zapRequest?.isPrivateZap() == true) {
zapRequest.cachedPrivateZap()?.pubKey ?: zapRequest.pubKey
} else {
zapRequest.pubKey
}
} else {
noteEvent.pubKey
}
} else {
it.author?.pubkeyHex
}
return it.event !is ChannelCreateEvent &&
it.event !is ChannelMetadataEvent &&
@ -96,10 +112,10 @@ class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>()
it.event !is BadgeDefinitionEvent &&
it.event !is BadgeProfilesEvent &&
it.event !is GiftWrapEvent &&
(it.event is LnZapEvent || it.author !== loggedInUser) &&
(filterParams.isGlobal || filterParams.followLists?.users?.contains(it.author?.pubkeyHex) == true) &&
(it.event is LnZapEvent || notifAuthor != loggedInUserHex) &&
(filterParams.isGlobal || filterParams.followLists?.users?.contains(notifAuthor) == true) &&
it.event?.isTaggedUser(loggedInUserHex) ?: false &&
(filterParams.isHiddenList || it.author == null || !account.isHidden(it.author!!.pubkeyHex)) &&
(filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) &&
tagsAnEventByUser(it, loggedInUserHex)
}

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

@ -159,7 +159,10 @@ fun DrawerContent(
)
ListContent(
modifier = Modifier.fillMaxWidth().weight(1f),
modifier =
Modifier
.fillMaxWidth()
.weight(1f),
drawerState,
openSheet,
accountViewModel,
@ -231,7 +234,8 @@ fun ProfileContentTemplate(
model = profilePicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier =
Modifier.width(100.dp)
Modifier
.width(100.dp)
.height(100.dp)
.clip(shape = CircleShape)
.border(3.dp, MaterialTheme.colorScheme.background, CircleShape)
@ -244,7 +248,10 @@ fun ProfileContentTemplate(
CreateTextWithEmoji(
text = bestDisplayName,
tags = tags,
modifier = Modifier.padding(top = 7.dp).clickable(onClick = onClick),
modifier =
Modifier
.padding(top = 7.dp)
.clickable(onClick = onClick),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
maxLines = 1,
@ -455,7 +462,10 @@ fun ListContent(
val context = LocalContext.current
Column(
modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
modifier =
modifier
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
) {
NavigationRow(
title = stringResource(R.string.profile),
@ -475,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 = {
@ -662,7 +681,8 @@ fun IconRow(
) {
Row(
modifier =
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
@ -693,10 +713,16 @@ fun IconRowRelays(
onClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().clickable { onClick() },
modifier =
Modifier
.fillMaxWidth()
.clickable { onClick() },
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 15.dp, horizontal = 25.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp, horizontal = 25.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
@ -737,7 +763,10 @@ fun BottomContent(
thickness = DividerThickness,
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClickableText(

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

@ -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

@ -90,6 +90,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
@ -104,6 +105,7 @@ fun ChatroomMessageCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel) {
WatchBlockAndReport(
@ -122,6 +124,7 @@ fun ChatroomMessageCompose(
accountViewModel,
nav,
onWantsToReply,
onWantsToEditDraft,
)
}
}
@ -138,6 +141,7 @@ fun NormalChatNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
val drawAuthorInfo by
remember(note) {
@ -251,6 +255,7 @@ fun NormalChatNote(
innerQuote,
backgroundBubbleColor,
onWantsToReply,
onWantsToEditDraft,
canPreview,
availableBubbleSize,
showDetails,
@ -264,7 +269,9 @@ fun NormalChatNote(
note = note,
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
onWantsToEditDraft = { onWantsToEditDraft(note) },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@ -278,6 +285,7 @@ private fun RenderBubble(
innerQuote: Boolean,
backgroundBubbleColor: MutableState<Color>,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
canPreview: Boolean,
availableBubbleSize: MutableState<Int>,
showDetails: State<Boolean>,
@ -308,6 +316,7 @@ private fun RenderBubble(
backgroundBubbleColor,
bubbleSize,
onWantsToReply,
onWantsToEditDraft,
canPreview,
showDetails,
accountViewModel,
@ -326,6 +335,7 @@ private fun MessageBubbleLines(
backgroundBubbleColor: MutableState<Color>,
bubbleSize: MutableState<Int>,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
canPreview: Boolean,
showDetails: State<Boolean>,
accountViewModel: AccountViewModel,
@ -340,19 +350,24 @@ private fun MessageBubbleLines(
)
}
RenderReplyRow(
note = baseNote,
innerQuote = innerQuote,
backgroundBubbleColor = backgroundBubbleColor,
accountViewModel = accountViewModel,
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,
@ -363,6 +378,9 @@ private fun MessageBubbleLines(
bubbleSize = bubbleSize,
availableBubbleSize = availableBubbleSize,
firstColumn = {
if (baseNote.isDraft()) {
DisplayDraftChat()
}
IncognitoBadge(baseNote)
ChatTimeAgo(baseNote)
RelayBadgesHorizontal(baseNote, accountViewModel, nav = nav)
@ -396,9 +414,10 @@ private fun RenderReplyRow(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
if (!innerQuote && note.replyTo?.lastOrNull() != null) {
RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply)
RenderReply(note, backgroundBubbleColor, accountViewModel, nav, onWantsToReply, onWantsToEditDraft)
}
}
@ -409,6 +428,7 @@ private fun RenderReply(
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
val replyTo =
@ -427,6 +447,7 @@ private fun RenderReply(
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = onWantsToReply,
onWantsToEditDraft = onWantsToEditDraft,
)
}
}
@ -437,6 +458,8 @@ private fun NoteRow(
note: Note,
canPreview: Boolean,
innerQuote: Boolean,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
backgroundBubbleColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -449,6 +472,18 @@ private fun NoteRow(
is ChannelMetadataEvent -> {
RenderChangeChannelMetadataNote(note)
}
is DraftEvent -> {
RenderDraftEvent(
note,
canPreview,
innerQuote,
onWantsToReply,
onWantsToEditDraft,
backgroundBubbleColor,
accountViewModel,
nav,
)
}
else -> {
RenderRegularTextNote(
note,
@ -463,6 +498,43 @@ private fun NoteRow(
}
}
@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,
)
}
}
}
@Composable
private fun ConstrainedStatusRow(
bubbleSize: MutableState<Int>,

Wyświetl plik

@ -29,8 +29,8 @@ 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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
@ -39,6 +39,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -48,6 +49,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
@ -82,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
@ -89,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
@ -105,15 +110,17 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Font12SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfEndPadding
import com.vitorpamplona.amethyst.ui.theme.HalfPadding
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size34dp
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
@ -122,6 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.channelNotePictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.normalWithTopMarginNoteModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyBackground
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.events.AppDefinitionEvent
@ -132,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
@ -160,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
@ -273,9 +281,7 @@ fun AcceptableNote(
is FileHeaderEvent -> FileHeaderDisplay(baseNote, false, accountViewModel)
is FileStorageHeaderEvent -> FileStorageHeaderDisplay(baseNote, false, accountViewModel)
else ->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) {
showPopup,
->
LongPressToQuickAction(baseNote = baseNote, accountViewModel = accountViewModel) { showPopup ->
CheckNewAndRenderNote(
baseNote = baseNote,
routeForLastRead = routeForLastRead,
@ -460,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) {
@ -477,6 +483,10 @@ fun InnerNoteWithReactions(
nav = nav,
)
}
} else {
if (baseNote.event is DraftEvent) {
Spacer(modifier = DoubleVertSpacer)
}
}
}
@ -515,16 +525,6 @@ fun NoteBody(
Spacer(modifier = Modifier.height(3.dp))
}
if (!makeItShort) {
ReplyRow(
baseNote,
unPackReply,
backgroundColor,
accountViewModel,
nav,
)
}
RenderNoteRow(
baseNote = baseNote,
backgroundColor = backgroundColor,
@ -532,6 +532,7 @@ fun NoteBody(
canPreview = canPreview,
editState = editState,
quotesLeft = quotesLeft,
unPackReply = unPackReply,
accountViewModel = accountViewModel,
nav = nav,
)
@ -551,6 +552,7 @@ private fun RenderNoteRow(
makeItShort: Boolean,
canPreview: Boolean,
quotesLeft: Int,
unPackReply: Boolean,
editState: State<GenericLoadable<EditState>>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
@ -560,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)
@ -607,6 +610,18 @@ private fun RenderNoteRow(
nav,
)
}
is ChatMessageEvent -> {
RenderChatMessage(
baseNote,
makeItShort,
canPreview,
quotesLeft,
backgroundColor,
editState,
accountViewModel,
nav,
)
}
is ClassifiedsEvent -> {
RenderClassifieds(
noteEvent,
@ -632,6 +647,7 @@ private fun RenderNoteRow(
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
accountViewModel,
nav,
@ -661,8 +677,8 @@ private fun RenderNoteRow(
nav,
)
}
else -> {
RenderTextEvent(
is ChannelMessageEvent ->
RenderChannelMessage(
baseNote,
makeItShort,
canPreview,
@ -672,6 +688,84 @@ 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 noteAuthor = noteState?.note?.author ?: return
val innerNote =
produceState(initialValue = accountViewModel.createTempCachedDraftNote(noteEvent, noteAuthor), noteEvent.id) {
if (value == null || value?.event?.id() != noteEvent.id) {
accountViewModel.createTempDraftNote(noteEvent, noteAuthor) {
value = it
}
}
}
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)
}
}
}
@ -709,81 +803,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,
@ -868,6 +888,29 @@ fun DisplayOtsIfInOriginal(
}
}
@Composable
fun DisplayDraft() {
Text(
"Draft",
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
modifier = HalfStartPadding,
)
}
@Composable
fun DisplayDraftChat() {
Text(
"Draft",
color = MaterialTheme.colorScheme.placeholderText,
modifier = HalfEndPadding,
fontWeight = FontWeight.Bold,
fontSize = Font12SP,
maxLines = 1,
)
}
@Composable
fun FirstUserInfoRow(
baseNote: Note,
@ -910,6 +953,10 @@ fun FirstUserInfoRow(
}
}
if (baseNote.isDraft()) {
DisplayDraft()
}
TimeAgo(baseNote)
MoreOptionsButton(baseNote, editState, accountViewModel, nav)

Wyświetl plik

@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.AlternateEmail
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
@ -84,6 +85,7 @@ import androidx.core.graphics.ColorUtils
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
@ -136,11 +138,16 @@ fun LongPressToQuickAction(
) {
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)
NoteQuickActionMenu(
note = baseNote,
popupExpanded = popupExpanded.value,
onDismiss = { popupExpanded.value = false },
accountViewModel = accountViewModel,
nav = {},
)
}
@Composable
@ -149,6 +156,39 @@ fun NoteQuickActionMenu(
popupExpanded: Boolean,
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
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) }
@ -163,6 +203,7 @@ fun NoteQuickActionMenu(
showBlockAlertDialog,
showDeleteAlertDialog,
showReportDialog,
onWantsToEditDraft,
)
}
@ -209,6 +250,7 @@ private fun RenderMainPopup(
showBlockAlertDialog: MutableState<Boolean>,
showDeleteAlertDialog: MutableState<Boolean>,
showReportDialog: MutableState<Boolean>,
onWantsToEditDraft: () -> Unit,
) {
val context = LocalContext.current
val primaryLight = lightenColor(MaterialTheme.colorScheme.primary, 0.1f)
@ -339,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) {
@ -389,14 +441,20 @@ fun NoteQuickActionItem(
onClick: () -> Unit,
) {
Column(
modifier = Modifier.size(70.dp).clickable { onClick() },
modifier =
Modifier
.size(70.dp)
.clickable { onClick() },
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.dp).padding(bottom = 5.dp),
modifier =
Modifier
.size(24.dp)
.padding(bottom = 5.dp),
tint = Color.White,
)
Text(text = label, fontSize = 12.sp, color = Color.White, textAlign = TextAlign.Center)
@ -527,7 +585,10 @@ fun QuickActionAlertDialog(
text = { Text(textContent) },
confirmButton = {
Row(
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
modifier =
Modifier
.padding(all = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextButton(onClick = onClickDontShowAgain) {

Wyświetl plik

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

Wyświetl plik

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

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,19 +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,
)
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("")
}
}
}
@ -236,10 +240,8 @@ fun AddBountyAmountDialog(
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost()
onClose()
}
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged(),
)

Wyświetl plik

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

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

@ -43,6 +43,7 @@ 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(
@ -51,6 +52,8 @@ fun RefreshingChatroomFeedView(
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: String? = null,
scrollStateKey: String? = null,
enablePullRefresh: Boolean = true,
) {
@ -63,6 +66,8 @@ fun RefreshingChatroomFeedView(
nav,
routeForLastRead,
onWantsToReply,
onWantsToEditDraft,
avoidDraft,
)
}
}
@ -76,6 +81,8 @@ fun RenderChatroomFeedView(
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: String? = null,
) {
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
@ -95,6 +102,8 @@ fun RenderChatroomFeedView(
nav,
routeForLastRead,
onWantsToReply,
onWantsToEditDraft,
avoidDraft,
)
}
is FeedState.Loading -> {
@ -112,6 +121,8 @@ fun ChatroomFeedLoaded(
nav: (String) -> Unit,
routeForLastRead: String,
onWantsToReply: (Note) -> Unit,
onWantsToEditDraft: (Note) -> Unit,
avoidDraft: String? = null,
) {
LaunchedEffect(state.feed.value.firstOrNull()) {
if (listState.firstVisibleItemIndex <= 1) {
@ -126,13 +137,17 @@ fun ChatroomFeedLoaded(
state = listState,
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item ->
ChatroomMessageCompose(
baseNote = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
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

@ -86,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.note.DisplayDraft
import com.vitorpamplona.amethyst.ui.note.DisplayOtsIfInOriginal
import com.vitorpamplona.amethyst.ui.note.HiddenNote
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
@ -94,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
@ -119,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
@ -150,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
@ -164,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
@ -446,6 +455,10 @@ fun NoteMaster(
DisplayPoW(pow)
}
if (note.isDraft()) {
DisplayDraft()
}
DisplayOtsIfInOriginal(note, editState, accountViewModel)
}
}
@ -473,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
@ -535,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(),
@ -561,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,
@ -605,7 +649,14 @@ fun NoteMaster(
ReactionsRow(note, true, editState, accountViewModel, nav)
}
NoteQuickActionMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
NoteQuickActionMenu(
note = note,
popupExpanded = popupExpanded,
onDismiss = { popupExpanded = false },
onWantsToEditDraft = { },
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@ -860,6 +911,7 @@ private fun RenderWikiHeaderForThreadPreview() {
false,
true,
quotesLeft = 3,
unPackReply = false,
backgroundColor,
editState,
accountViewModel,

Wyświetl plik

@ -71,8 +71,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 +575,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,
@ -1209,6 +1215,14 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
baseNote: Note,
onMore: () -> Unit,
) {
if (baseNote.isDraft()) {
toast(
R.string.draft_note,
R.string.it_s_not_possible_to_quote_to_a_draft_note,
)
return
}
if (isWriteable()) {
if (hasBoosted(baseNote)) {
deleteBoostsTo(baseNote)
@ -1304,6 +1318,43 @@ class AccountViewModel(val account: Account, val settings: SettingsState) : View
}
}
suspend fun deleteDraft(draftTag: String) {
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) {
noteEvent.cachedDraft(account.signer) {
onReady(createTempDraftNote(it, author))
}
}
}
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 bechLinkCache = CachedLoadedBechLink(this)
class CachedLoadedBechLink(val accountViewModel: AccountViewModel) : GenericBaseCache<String, LoadedBechLink>(20) {

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
@ -163,12 +164,17 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
@Composable
fun ChannelScreen(
@ -187,6 +193,7 @@ fun ChannelScreen(
}
}
@OptIn(FlowPreview::class)
@Composable
fun PrepareChannelViewModels(
baseChannel: Channel,
@ -289,7 +296,11 @@ fun ChannelScreen(
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Channel/${channel.idHex}",
avoidDraft = newPostModel.draftTag,
onWantsToReply = { replyTo.value = it },
onWantsToEditDraft = {
newPostModel.load(accountViewModel, null, null, null, null, it)
},
)
}
@ -299,49 +310,74 @@ fun ChannelScreen(
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,
)
} else if (channel is LiveActivitiesChannel) {
accountViewModel.account.sendLiveMessage(
message = tagger.message,
toChannel = channel.address,
replyTo = tagger.eTags,
mentions = tagger.pTags,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
)
}
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?,
@ -366,6 +402,7 @@ fun DisplayReplyingToNote(
accountViewModel = accountViewModel,
nav = nav,
onWantsToReply = {},
onWantsToEditDraft = {},
)
}
@ -665,7 +702,12 @@ fun ShowVideoStreaming(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = remember { Modifier.fillMaxWidth().heightIn(min = 50.dp, max = 300.dp) },
modifier =
remember {
Modifier
.fillMaxWidth()
.heightIn(min = 50.dp, max = 300.dp)
},
) {
val zoomableUrlVideo =
remember(streamingInfo) {

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
@ -122,8 +122,13 @@ import com.vitorpamplona.quartz.events.findURLs
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
@Composable
fun ChatroomScreen(
@ -206,6 +211,7 @@ fun LoadRoomByAuthor(
content(room)
}
@OptIn(FlowPreview::class)
@Composable
fun PrepareChatroomViewModels(
room: ChatroomKey,
@ -315,7 +321,13 @@ fun ChatroomScreen(
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = "Room/${room.hashCode()}",
onWantsToReply = { replyTo.value = it },
avoidDraft = newPostModel.draftTag,
onWantsToReply = {
replyTo.value = it
},
onWantsToEditDraft = {
newPostModel.load(accountViewModel, null, null, null, null, it)
},
)
}
@ -325,33 +337,27 @@ fun ChatroomScreen(
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) {
val urls = findURLs(newPostModel.message.text)
val usedAttachments = newPostModel.nip94attachments.filter { it.urls().intersect(urls.toSet()).isNotEmpty() }
innerSendPost(newPostModel, room, replyTo, accountViewModel, null)
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,
)
} else {
accountViewModel.account.sendPrivateMessage(
message = newPostModel.message.text,
toUser = room.users.first(),
replyingTo = replyTo.value,
mentions = null,
wantsToMarkAsSensitive = false,
nip94attachments = usedAttachments,
)
}
accountViewModel.deleteDraft(newPostModel.draftTag)
newPostModel.message = TextFieldValue("")
newPostModel.draftTag = UUID.randomUUID().toString()
replyTo.value = null
feedViewModel.sendToTop()
}
@ -359,6 +365,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,
@ -551,7 +590,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 ->
@ -560,7 +599,7 @@ fun ChatroomHeader(
baseUser = baseUser,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
)
}
}
@ -569,7 +608,7 @@ fun ChatroomHeader(
room = room,
modifier = modifier,
accountViewModel = accountViewModel,
nav = nav,
onClick = onClick,
)
}
}
@ -579,13 +618,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(
@ -601,14 +640,9 @@ fun ChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
UsernameDisplay(baseUser)
ObserveDisplayNip05Status(baseUser, accountViewModel = accountViewModel, nav = nav)
}
}
}
HorizontalDivider(
thickness = DividerThickness,
)
}
}
@ -617,12 +651,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,
@ -640,15 +672,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

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

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

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

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

@ -0,0 +1,81 @@
/**
* 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.preview
import org.junit.Assert.assertEquals
import org.junit.Test
class MetaTagsParserTest {
@Test
fun testParse() {
val input =
"""<html>
| <head>
| <meta charset="utf-8">
| <meta http-equiv="content-type" content="text/html; charset=utf-8">
| <meta property="og:title" content=title>
| <meta property="og:description" content='description'>
| <meta property="og:image" content="https://example.com/img/foo.png">
| <!-- edge cases -->
| <meta
| name="newline"
| content="newline"
| >
| <meta name="space before gt" >
| <meta name ="space before =">
| <meta name= "space after =">
| <META NAME="CAPITAL">
| <meta name="character reference" content="&lt;meta&gt;">
| <meta name="attr value with end of head doesn't harm" content="<head>bang!</head>">
| <meta name="ignore tags with duplicated attr" name="dup">
| </head>
| <body>
| <meta name="ignore meta tags in body">
| </body>
|</html>
""".trimMargin()
val exp =
listOf(
listOf("charset" to "utf-8"),
listOf("http-equiv" to "content-type", "content" to "text/html; charset=utf-8"),
listOf("property" to "og:title", "content" to "title"),
listOf("property" to "og:description", "content" to "description"),
listOf("property" to "og:image", "content" to "https://example.com/img/foo.png"),
listOf("name" to "newline", "content" to "newline"),
listOf("name" to "space before gt"),
listOf("name" to "space before ="),
listOf("name" to "space after ="),
listOf("name" to "CAPITAL"),
listOf("name" to "character reference", "content" to "<meta>"),
listOf("name" to "attr value with end of head doesn't harm", "content" to "<head>bang!</head>"),
)
val metaTags = MetaTagsParser.parse(input).toList()
println(metaTags)
assertEquals(exp.size, metaTags.size)
metaTags.zip(exp).forEach { (meta, expAttrs) ->
expAttrs.forEach { (name, expValue) ->
assertEquals(expValue, meta.attr(name))
}
}
}
}

Wyświetl plik

@ -0,0 +1,301 @@
/**
* 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.preview
import kotlinx.collections.immutable.toImmutableMap
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()] ?: ""
}
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" && t.isEnd) {
break
}
if (t.name == "meta") {
val attrs = parseAttrs(t.attrPart) ?: continue
yield(MetaTag(attrs))
}
}
}
private data class RawTag(val isEnd: Boolean, val name: String, val attrPart: String)
private class TagScanner(private val input: String) {
private var p = 0
fun exhausted(): Boolean = p >= input.length
private fun peek(): Char = input[p]
private fun consume(): Char = input[p++]
private fun skipWhile(pred: (Char) -> Boolean) {
while (!this.exhausted() && pred(this.peek())) {
this.consume()
}
}
private fun skipSpaces() {
this.skipWhile { it.isWhitespace() }
}
fun nextTag(): RawTag? {
skipWhile { it != '<' }
consume()
// read tag name
val isEnd = peek() == '/'
if (isEnd) {
consume()
}
val nameStart = p
skipWhile { !it.isWhitespace() && it != '>' }
val nameEnd = p
// seek to start of attrs part
skipSpaces()
val attrsStart = p
// skip until end of tag
var quote: Char? = null
while (!exhausted()) {
val c = consume()
when {
// `/>` out of quote -> end of tag
quote == null && c == '/' && peek() == '>' -> {
consume()
break
}
// `>` out of quote -> end of tag
quote == null && c == '>' -> {
break
}
// entering quote
quote == null && (c == '\'' || c == '"') -> {
quote = c
}
// leaving quote
quote != null && c == quote -> {
quote = null
}
}
}
val attrsEnd = p - 1
val name = input.slice(nameStart..<nameEnd)
if (!name.matches(Regex("""[0-9a-zA-Z]+"""))) {
return null
}
val attrsPart = input.slice(attrsStart..<attrsEnd)
return RawTag(isEnd, name.lowercase(), attrsPart)
}
}
// map of HTML element attribute name to its value, with additional logics:
// - attribute names are matched in a case-insensitive manner
// - attribute names never duplicate
// - commonly used character references in attribute values are resolved
private class Attrs {
companion object {
val RE_CHAR_REF = Regex("""&(\w+)(;?)""")
val BASE_CHAR_REFS =
mapOf(
"amp" to "&",
"AMP" to "&",
"quot" to "\"",
"QUOT" to "\"",
"lt" to "<",
"LT" to "<",
"gt" to ">",
"GT" to ">",
)
val CHAR_REFS =
mapOf(
"apos" to "'",
"equals" to "=",
"grave" to "`",
"DiacriticalGrave" to "`",
)
fun replaceCharRefs(match: MatchResult): String {
val bcr = BASE_CHAR_REFS[match.groupValues[1]]
if (bcr != null) {
return bcr
}
// non-base char refs must be terminated by ';'
if (match.groupValues[2].isNotEmpty()) {
val cr = CHAR_REFS[match.groupValues[1]]
if (cr != null) {
return cr
}
}
return match.value
}
}
private val attrs = mutableMapOf<String, String>()
fun add(attr: Pair<String, String>) {
val name = attr.first.lowercase()
if (attrs.containsKey(name)) {
throw IllegalArgumentException("duplicated attribute name: $name")
}
val value = attr.second.replace(RE_CHAR_REF, Companion::replaceCharRefs)
attrs += Pair(name, value)
}
fun freeze(): Map<String, String> = attrs.toImmutableMap()
}
private enum class State {
NAME,
BEFORE_EQ,
AFTER_EQ,
VALUE,
SPACE,
}
private fun parseAttrs(input: String): Map<String, String>? {
val attrs = Attrs()
var state = State.NAME
var nameBegin = 0
var nameEnd = 0
var valueBegin = 0
var valueQuote: Char? = null
input.forEachIndexed { i, c ->
when (state) {
State.NAME -> {
when {
c == '=' -> {
nameEnd = i
state = State.AFTER_EQ
}
c.isWhitespace() -> {
nameEnd = i
state = State.BEFORE_EQ
}
NON_ATTR_NAME_CHARS.contains(c) || c.isISOControl() || !c.isDefined() -> {
return null
}
}
}
State.BEFORE_EQ -> {
when {
c == '=' -> {
state = State.AFTER_EQ
}
c.isWhitespace() -> {}
else -> return null
}
}
State.AFTER_EQ -> {
when {
c.isWhitespace() -> {}
c == '\'' || c == '"' -> {
valueBegin = i + 1
valueQuote = c
state = State.VALUE
}
else -> {
valueBegin = i
valueQuote = null
state = State.VALUE
}
}
}
State.VALUE -> {
var attr: Pair<String, String>? = null
when {
valueQuote != null -> {
if (c == valueQuote) {
attr =
Pair(
input.slice(nameBegin..<nameEnd),
input.slice(valueBegin..<i),
)
}
}
valueQuote == null -> {
when {
c.isWhitespace() -> {
attr =
Pair(
input.slice(nameBegin..<nameEnd),
input.slice(valueBegin..<i),
)
}
i == input.length - 1 -> {
attr =
Pair(
input.slice(nameBegin..<nameEnd),
input.slice(valueBegin..i),
)
}
NON_UNQUOTED_ATTR_VALUE_CHARS.contains(c) -> {
return null
}
}
}
}
if (attr != null) {
runCatching { attrs.add(attr) }.getOrNull() ?: return null
state = State.SPACE
}
}
State.SPACE -> {
if (!c.isWhitespace()) {
nameBegin = i
state = State.NAME
}
}
}
}
return attrs.freeze()
}
}

Wyświetl plik

@ -15,12 +15,11 @@ 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"
jna = "5.14.0"
jsoup = "1.17.2"
junit = "4.13.2"
kotlin = "1.9.22"
kotlinxCollectionsImmutable = "0.3.7"
@ -93,7 +92,6 @@ google-mlkit-language-id = { group = "com.google.mlkit", name = "language-id", v
google-mlkit-translate = { group = "com.google.mlkit", name = "translate", version.ref = "translate" }
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodiumAndroid" }

File diff suppressed because one or more lines are too long

Wyświetl plik

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

Wyświetl plik

@ -82,11 +82,12 @@ class ChatMessageEvent(
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
nip94attachments: List<FileHeaderEvent>? = null,
isDraft: Boolean,
onReady: (ChatMessageEvent) -> Unit,
) {
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()))
@ -106,7 +107,11 @@ class ChatMessageEvent(
}
// tags.add(arrayOf("alt", alt))
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
if (isDraft) {
signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), msg, onReady)
} else {
signer.sign(createdAt, KIND, tags.toTypedArray(), msg, onReady)
}
}
}
}

Wyświetl plik

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

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

@ -0,0 +1,210 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.events
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.signers.NostrSigner
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
class DraftEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Transient private var cachedInnerEvent: Map<HexKey, Event?> = mapOf()
override fun isContentEncoded() = true
fun isDeleted() = content == ""
fun preCachedDraft(signer: NostrSigner): Event? {
return cachedInnerEvent[signer.pubKey]
}
fun preCachedDraft(pubKey: HexKey): Event? {
return cachedInnerEvent[pubKey]
}
fun allCache() = cachedInnerEvent.values
fun addToCache(
pubKey: HexKey,
innerEvent: Event,
) {
cachedInnerEvent = cachedInnerEvent + Pair(pubKey, innerEvent)
}
fun cachedDraft(
signer: NostrSigner,
onReady: (Event) -> Unit,
) {
cachedInnerEvent[signer.pubKey]?.let {
onReady(it)
return
}
decrypt(signer) { draft ->
addToCache(signer.pubKey, draft)
onReady(draft)
}
}
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: 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", "${innerEvent.kind}"))
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

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

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

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -21,7 +21,9 @@
package com.vitorpamplona.quartz.signers
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventFactory
import com.vitorpamplona.quartz.events.LnZapPrivateEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
@ -62,4 +64,26 @@ abstract class NostrSigner(val pubKey: HexKey) {
event: LnZapRequestEvent,
onReady: (LnZapPrivateEvent) -> Unit,
)
fun <T : Event> assembleRumor(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
content: String,
onReady: (T) -> Unit,
) {
val id = Event.generateId(pubKey, createdAt, kind, tags, content).toHexKey()
onReady(
EventFactory.create(
id = id,
pubKey = pubKey,
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
sig = "",
) as T,
)
}
}