diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a8323ba28..79b71775f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -102,6 +102,7 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.signers.NostrSigner @@ -1796,6 +1797,78 @@ class Account( } } + fun sendTorrentComment( + message: String, + replyTo: List?, + mentions: List?, + zapReceiver: List? = null, + wantsToMarkAsSensitive: Boolean, + zapRaiserAmount: Long? = null, + replyingTo: String?, + root: String, + directMentions: Set, + forkedFrom: Event?, + relayList: List? = null, + geohash: String? = null, + nip94attachments: List? = null, + draftTag: String?, + ) { + if (!isWriteable()) return + + val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex } + val mentionsHex = mentions?.map { it.pubkeyHex } + val addresses = replyTo?.mapNotNull { it.address() } ?: emptyList() + + TorrentCommentEvent.create( + message = message, + replyTos = repliesToHex, + mentions = mentionsHex, + zapReceiver = zapReceiver, + markAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = zapRaiserAmount, + replyingTo = replyingTo, + torrent = root, + directMentions = directMentions, + geohash = geohash, + nip94attachments = nip94attachments, + forkedFrom = forkedFrom, + signer = signer, + isDraft = draftTag != null, + ) { + if (draftTag != null) { + if (message.isBlank()) { + deleteDraft(draftTag) + } else { + DraftEvent.create(draftTag, it, signer) { draftEvent -> + val newRelayList = getPrivateOutboxRelayList()?.relays() + if (newRelayList != null) { + Client.sendPrivately(draftEvent, newRelayList) + } else { + Client.send(draftEvent, relayList = relayList) + } + LocalCache.justConsume(draftEvent, null) + } + } + } 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 deleteDraft(draftTag: String) { val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) LocalCache.getAddressableNoteIfExists(key)?.let { note -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 4e46d2d00..97c23bf5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -114,6 +114,8 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.WikiNoteEvent @@ -463,6 +465,61 @@ object LocalCache { refreshObservers(note) } + fun consume( + event: TorrentEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event, relay)) { + return + } + + note.loadEvent(event, author, emptyList()) + + refreshObservers(note) + } + + fun consume( + event: TorrentCommentEvent, + relay: Relay?, + ) { + val note = getOrCreateNote(event.id) + val author = getOrCreateUser(event.pubKey) + + if (relay != null) { + author.addRelayBeingUsed(relay, event.createdAt) + note.addRelay(relay) + } + + // Already processed this event. + if (note.event != null) return + + if (antiSpam.isSpam(event, relay)) { + return + } + + val replyTo = computeReplyTo(event) + + note.loadEvent(event, author, replyTo) + + // Counts the replies + replyTo.forEach { + it.addReply(note) + } + + refreshObservers(note) + } + fun consume( event: NIP90ContentDiscoveryResponseEvent, relay: Relay? = null, @@ -795,6 +852,8 @@ object LocalCache { .tagsWithoutCitations() .filter { it != event.activity()?.toTag() } .mapNotNull { checkGetOrCreateNote(it) } + is TorrentCommentEvent -> + event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) } is DraftEvent -> { event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) } @@ -1303,6 +1362,10 @@ object LocalCache { getChannelIfExists(it.toTag())?.removeNote(deleteNote) } + (deletedEvent as? TorrentCommentEvent)?.torrent()?.let { + getNoteIfExists(it)?.removeReply(deleteNote) + } + if (deletedEvent is PrivateDmEvent) { val author = deleteNote.author val recipient = @@ -2664,6 +2727,8 @@ object LocalCache { is StatusEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay) is TextNoteModificationEvent -> consume(event, relay) + is TorrentEvent -> consume(event, relay) + is TorrentCommentEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay) is WikiNoteEvent -> consume(event, relay) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 9fdc9bf06..016ad68f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { private var nextEventsToWatch = setOf() @@ -179,6 +180,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { DeletionEvent.KIND, NIP90ContentDiscoveryResponseEvent.KIND, NIP90StatusEvent.KIND, + TorrentCommentEvent.KIND, ), tags = mapOf("e" to it.map { it.idHex }), since = findMinimumEOSEs(it), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index 723a02276..daa439767 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.WikiNoteEvent object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { @@ -89,6 +91,23 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { ) } + fun createUserPostsFilter2() = + user?.let { + TypedFilter( + types = COMMON_FEED_TYPES, + filter = + SincePerRelayFilter( + kinds = + listOf( + TorrentEvent.KIND, + TorrentCommentEvent.KIND, + ), + authors = listOf(it.pubkeyHex), + limit = 20, + ), + ) + } + fun createUserReceivedZapsFilter() = user?.let { TypedFilter( @@ -188,6 +207,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { listOfNotNull( createUserInfoFilter(), createUserPostsFilter(), + createUserPostsFilter2(), createProfileGalleryFilter(), createFollowFilter(), createFollowersFilter(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 9d6edbad1..638e0a78b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -69,6 +69,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.findURLs import kotlinx.coroutines.CancellationException @@ -681,6 +683,74 @@ open class NewPostViewModel : ViewModel() { nip94attachments = usedAttachments, draftTag = localDraft, ) + } else if (originalNote?.event is TorrentCommentEvent) { + val originalNoteEvent = originalNote?.event as TorrentCommentEvent + // adds markers + val rootId = + originalNoteEvent.torrent() // if it has a marker as root + ?: originalNote + ?.replyTo + ?.firstOrNull { it.event != null && it.replyTo?.isEmpty() == true } + ?.idHex // if it has loaded events with zero replies in the reply list + ?: originalNote?.replyTo?.firstOrNull()?.idHex // old rules, first item is root. + ?: originalNote?.idHex + + if (rootId != null) { + // There must be a torrent ID + val replyId = originalNote?.idHex + + val replyToSet = + if (forkedFromNote != null) { + (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } + } else { + tagger.eTags + } + + account?.sendTorrentComment( + message = tagger.message, + replyTo = replyToSet, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + replyingTo = replyId, + root = rootId, + directMentions = tagger.directMentions, + forkedFrom = forkedFromNote?.event as? Event, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + draftTag = localDraft, + ) + } + } else if (originalNote?.event is TorrentEvent) { + val originalNoteEvent = originalNote?.event as TorrentEvent + // adds markers + val rootId = originalNoteEvent.id + + val replyToSet = + if (forkedFromNote != null) { + (listOfNotNull(forkedFromNote) + (tagger.eTags ?: emptyList())).ifEmpty { null } + } else { + tagger.eTags + } + + account?.sendTorrentComment( + message = tagger.message, + replyTo = replyToSet, + mentions = tagger.pTags, + zapReceiver = zapReceiver, + wantsToMarkAsSensitive = wantsToMarkAsSensitive, + zapRaiserAmount = localZapRaiserAmount, + replyingTo = null, + root = rootId, + directMentions = tagger.directMentions, + forkedFrom = forkedFromNote?.event as? Event, + relayList = relayList, + geohash = geoHash, + nip94attachments = usedAttachments, + draftTag = localDraft, + ) } else { if (wantsPoll) { account?.sendPoll( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt index 5c45a676e..d8e5bbbe8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileConversationsFeedFilter.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent class UserProfileConversationsFeedFilter( val user: User, @@ -59,7 +60,8 @@ class UserProfileConversationsFeedFilter( it.event is TextNoteEvent || it.event is PollNoteEvent || it.event is ChannelMessageEvent || - it.event is LiveActivitiesChatMessageEvent + it.event is LiveActivitiesChatMessageEvent || + it.event is TorrentCommentEvent ) && !it.isNewThread() && account.isAcceptable(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt index aecedf631..5bc32e006 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileNewThreadFeedFilter.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.TextNoteEvent +import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.WikiNoteEvent class UserProfileNewThreadFeedFilter( @@ -73,7 +74,8 @@ class UserProfileNewThreadFeedFilter( it.event is PollNoteEvent || it.event is HighlightEvent || it.event is AudioTrackEvent || - it.event is AudioHeaderEvent + it.event is AudioHeaderEvent || + it.event is TorrentEvent ) && it.isNewThread() && account.isAcceptable(it) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 181c08112..49cdeebbb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -112,6 +112,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderReaction import com.vitorpamplona.amethyst.ui.note.types.RenderReport import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent +import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -182,6 +184,8 @@ import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent @@ -754,6 +758,27 @@ private fun RenderNoteRow( accountViewModel, nav, ) + is TorrentEvent -> + RenderTorrent( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + + is TorrentCommentEvent -> + RenderTorrentComment( + baseNote, + makeItShort, + canPreview, + quotesLeft, + unPackReply, + backgroundColor, + editState, + accountViewModel, + nav, + ) + else -> { RenderTextEvent( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt new file mode 100644 index 000000000..2221a981e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Torrent.kt @@ -0,0 +1,286 @@ +/** + * 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 android.content.Intent +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.relays.countToHumanReadableBytes +import com.vitorpamplona.amethyst.ui.components.ShowMoreButton +import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon +import com.vitorpamplona.amethyst.ui.note.getGradient +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size20dp +import com.vitorpamplona.amethyst.ui.theme.Size30dp +import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.quartz.events.TorrentEvent +import com.vitorpamplona.quartz.events.TorrentFile +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlin.coroutines.cancellation.CancellationException + +@Preview +@Composable +fun TorrentPreview() { + val accountViewModel = mockAccountViewModel() + val nav: (String) -> Unit = {} + + val torrent = + runBlocking { + withContext(Dispatchers.IO) { + val torrent = + TorrentEvent( + id = "e1ab66dd66e6ac4f32119deacf80d7c50787705d343d3bb896c099cf93821757", + pubKey = "0aa39e5aef99a000a7bdb0b499158c92bc4aa20fb65931a52d055b5eb6dff738", + createdAt = 1724177932, + tags = + arrayOf( + arrayOf("title", "bitcoin-core-27.0"), + arrayOf("btih", "2b2d123e5e831b245fb1dc5b8b71f89de4a90d00"), + arrayOf("t", "application"), + arrayOf("file", "SHA256SUMS", "2842"), + arrayOf("file", "SHA256SUMS.asc", "7590"), + arrayOf("file", "SHA256SUMS.ots", "573"), + arrayOf("file", "bitcoin-27.0-aarch64-linux-gnu-debug.tar.gz", "456216269"), + arrayOf("file", "bitcoin-27.0-aarch64-linux-gnu.tar.gz", "46953175"), + arrayOf("file", "bitcoin-27.0-arm-linux-gnueabihf-debug.tar.gz", "449092874"), + arrayOf("file", "bitcoin-27.0-arm-linux-gnueabihf.tar.gz", "42887090"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin-unsigned.tar.gz", "16417606"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin-unsigned.zip", "16451447"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin.tar.gz", "36433324"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin.zip", "16217968"), + arrayOf("file", "bitcoin-27.0-codesignatures-27.0.tar.gz", "335808"), + arrayOf("file", "bitcoin-27.0-powerpc64-linux-gnu-debug.tar.gz", "467821947"), + arrayOf("file", "bitcoin-27.0-powerpc64-linux-gnu.tar.gz", "53252604"), + arrayOf("file", "bitcoin-27.0-powerpc64le-linux-gnu-debug.tar.gz", "460024501"), + arrayOf("file", "bitcoin-27.0-powerpc64le-linux-gnu.tar.gz", "52020438"), + arrayOf("file", "bitcoin-27.0-riscv64-linux-gnu-debug.tar.gz", "355544520"), + arrayOf("file", "bitcoin-27.0-riscv64-linux-gnu.tar.gz", "47063263"), + arrayOf("file", "bitcoin-27.0-win64-debug.zip", "522357199"), + arrayOf("file", "bitcoin-27.0-win64-setup-unsigned.exe", "32348633"), + arrayOf("file", "bitcoin-27.0-win64-setup.exe", "32359120"), + arrayOf("file", "bitcoin-27.0-win64-unsigned.tar.gz", "32296875"), + arrayOf("file", "bitcoin-27.0-win64.zip", "46821837"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin-unsigned.tar.gz", "17160472"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin-unsigned.zip", "17213924"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin.tar.gz", "38428165"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin.zip", "17474503"), + arrayOf("file", "bitcoin-27.0-x86_64-linux-gnu-debug.tar.gz", "471529844"), + arrayOf("file", "bitcoin-27.0-x86_64-linux-gnu.tar.gz", "48849225"), + arrayOf("file", "bitcoin-27.0.tar.gz", "13082621"), + ), + content = "bitcoin-core-27.0", + sig = "40e1ccfdc38a32e6c164bb66e50df0cd3769e0431137a07709534a72b462dfcbc40106560d0dd66841fef4cbb7aece7db64e83a0fbe414759d4d9a799e522c57", + ) + + LocalCache.justConsume(torrent, null) + + LocalCache.getOrCreateNote("e1ab66dd66e6ac4f32119deacf80d7c50787705d343d3bb896c099cf93821757") + } + } + + ThemeComparisonColumn( + toPreview = { + RenderTorrent( + torrent, + remember { mutableStateOf(Color.Transparent) }, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) +} + +@Composable +fun RenderTorrent( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val noteEvent = note.event as? TorrentEvent ?: return + + val name = (noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION) + val size = " (" + countToHumanReadableBytes(noteEvent.totalSizeBytes()) + ")" + + val description = + if (noteEvent.content() != name) { + noteEvent.content() + } else { + null + } + + DisplayFileList( + noteEvent.files().toImmutableList(), + name + size, + description, + noteEvent::toMagnetLink, + backgroundColor, + accountViewModel, + nav, + ) +} + +@Composable +fun DisplayFileList( + files: ImmutableList, + name: String, + description: String?, + link: () -> String, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + val toMembersShow = + if (expanded) { + files + } else { + files.take(6) + } + + Column { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = + Modifier + .padding(horizontal = Size5dp) + .fillMaxWidth(), + ) { + Text( + text = name, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + val context = LocalContext.current + + IconButton(onClick = { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link())) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + + ContextCompat.startActivity(context, intent, null) + } catch (e: Exception) { + if (e is CancellationException) throw e + accountViewModel.toast(R.string.torrent_failure, R.string.torrent_no_apps) + } + }, Modifier.size(Size30dp)) { + DownloadForOfflineIcon(Size20dp, MaterialTheme.colorScheme.onBackground) + } + } + + description?.let { + Text( + text = it, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = Color.Gray, + ) + } + + Box { + Column(modifier = Modifier.padding(top = 5.dp)) { + toMembersShow.forEach { fileName -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = fileName.fileName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(start = 10.dp, end = 5.dp) + .weight(1f), + ) + + fileName.bytes?.let { + Text( + text = countToHumanReadableBytes(it), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(start = 5.dp, end = 10.dp), + ) + } + } + } + } + + if (files.size > 3 && !expanded) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .background(getGradient(backgroundColor)), + ) { + ShowMoreButton { expanded = !expanded } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt new file mode 100644 index 000000000..cb2788869 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -0,0 +1,260 @@ +/** + * 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.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FileOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.relays.countToHumanReadableBytes +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.LoadNote +import com.vitorpamplona.amethyst.ui.navigation.routeFor +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.replyModifier +import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.TorrentEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Preview +@Composable +fun TorrentCommentPreview() { + val accountViewModel = mockAccountViewModel() + val nav: (String) -> Unit = {} + + val comment = + runBlocking { + withContext(Dispatchers.IO) { + val torrent = + TorrentEvent( + id = "e1ab66dd66e6ac4f32119deacf80d7c50787705d343d3bb896c099cf93821757", + pubKey = "0aa39e5aef99a000a7bdb0b499158c92bc4aa20fb65931a52d055b5eb6dff738", + createdAt = 1724177932, + tags = + arrayOf( + arrayOf("title", "bitcoin-core-27.0"), + arrayOf("btih", "2b2d123e5e831b245fb1dc5b8b71f89de4a90d00"), + arrayOf("t", "application"), + arrayOf("file", "SHA256SUMS", "2842"), + arrayOf("file", "SHA256SUMS.asc", "7590"), + arrayOf("file", "SHA256SUMS.ots", "573"), + arrayOf("file", "bitcoin-27.0-aarch64-linux-gnu-debug.tar.gz", "456216269"), + arrayOf("file", "bitcoin-27.0-aarch64-linux-gnu.tar.gz", "46953175"), + arrayOf("file", "bitcoin-27.0-arm-linux-gnueabihf-debug.tar.gz", "449092874"), + arrayOf("file", "bitcoin-27.0-arm-linux-gnueabihf.tar.gz", "42887090"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin-unsigned.tar.gz", "16417606"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin-unsigned.zip", "16451447"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin.tar.gz", "36433324"), + arrayOf("file", "bitcoin-27.0-arm64-apple-darwin.zip", "16217968"), + arrayOf("file", "bitcoin-27.0-codesignatures-27.0.tar.gz", "335808"), + arrayOf("file", "bitcoin-27.0-powerpc64-linux-gnu-debug.tar.gz", "467821947"), + arrayOf("file", "bitcoin-27.0-powerpc64-linux-gnu.tar.gz", "53252604"), + arrayOf("file", "bitcoin-27.0-powerpc64le-linux-gnu-debug.tar.gz", "460024501"), + arrayOf("file", "bitcoin-27.0-powerpc64le-linux-gnu.tar.gz", "52020438"), + arrayOf("file", "bitcoin-27.0-riscv64-linux-gnu-debug.tar.gz", "355544520"), + arrayOf("file", "bitcoin-27.0-riscv64-linux-gnu.tar.gz", "47063263"), + arrayOf("file", "bitcoin-27.0-win64-debug.zip", "522357199"), + arrayOf("file", "bitcoin-27.0-win64-setup-unsigned.exe", "32348633"), + arrayOf("file", "bitcoin-27.0-win64-setup.exe", "32359120"), + arrayOf("file", "bitcoin-27.0-win64-unsigned.tar.gz", "32296875"), + arrayOf("file", "bitcoin-27.0-win64.zip", "46821837"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin-unsigned.tar.gz", "17160472"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin-unsigned.zip", "17213924"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin.tar.gz", "38428165"), + arrayOf("file", "bitcoin-27.0-x86_64-apple-darwin.zip", "17474503"), + arrayOf("file", "bitcoin-27.0-x86_64-linux-gnu-debug.tar.gz", "471529844"), + arrayOf("file", "bitcoin-27.0-x86_64-linux-gnu.tar.gz", "48849225"), + arrayOf("file", "bitcoin-27.0.tar.gz", "13082621"), + ), + content = "bitcoin-core-27.0", + sig = "40e1ccfdc38a32e6c164bb66e50df0cd3769e0431137a07709534a72b462dfcbc40106560d0dd66841fef4cbb7aece7db64e83a0fbe414759d4d9a799e522c57", + ) + + val comment = + TorrentCommentEvent( + id = "040aba32010b5adf7cb917054e894e86c8ea7a2bcee448b2266c493f3140e9a0", + pubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + createdAt = 1724276875, + tags = arrayOf(arrayOf("e", "e1ab66dd66e6ac4f32119deacf80d7c50787705d343d3bb896c099cf93821757", "wss://relay.damus.io/", "", "0aa39e5aef99a000a7bdb0b499158c92bc4aa20fb65931a52d055b5eb6dff738", "root")), + content = "nice!", + sig = "014391c310b1eebb807da4c9b11563126f2b795c9372a9432cae4dd2c0695b88584bb1c68814554c9b1a47626e3d60983e3653c29d0fdbc3a474277c140b95c3", + ) + + LocalCache.justConsume(torrent, null) + LocalCache.justConsume(comment, null) + LocalCache.getOrCreateNote("040aba32010b5adf7cb917054e894e86c8ea7a2bcee448b2266c493f3140e9a0") + } + } + + ThemeComparisonColumn( + toPreview = { + RenderTorrentComment( + comment, + false, + true, + 3, + true, + remember { mutableStateOf(Color.Transparent) }, + EmptyState, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) +} + +@Composable +fun RenderTorrentComment( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + unPackReply: Boolean, + backgroundColor: MutableState, + editState: State>, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + Column { + val noteEvent = note.event + + if (unPackReply) { + val torrentInfo = + remember(noteEvent) { + if (noteEvent is TorrentCommentEvent) { + noteEvent.torrent() + } else { + null + } + } + + torrentInfo?.let { + TorrentHeader( + torrentHex = it, + 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, + ) + } +} + +@Composable +fun TorrentHeader( + torrentHex: String, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + LoadNote(baseNoteHex = torrentHex, accountViewModel = accountViewModel) { + if (it != null) { + ShortTorrentHeader(it, modifier, accountViewModel, nav) + } + } +} + +@Composable +fun ShortTorrentHeader( + baseNote: Note, + modifier: Modifier, + accountViewModel: AccountViewModel, + nav: (String) -> Unit, +) { + val channelState by baseNote.live().metadata.observeAsState() + val note = channelState?.note ?: return + val noteEvent = note.event as? TorrentEvent ?: return + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + modifier.clickable { + routeFor(baseNote, accountViewModel.userProfile())?.let { nav(it) } + }, + ) { + Icons.Outlined.FileOpen + + Icon( + imageVector = Icons.Outlined.FileOpen, + contentDescription = stringRes(id = R.string.torrent_file), + modifier = Modifier.size(20.dp), + ) + + Text( + text = remember(channelState) { noteEvent.title() ?: TorrentEvent.ALT_DESCRIPTION }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + ) + + Text( + text = remember(channelState) { countToHumanReadableBytes(noteEvent.totalSizeBytes()) }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 5.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 90e30abff..a5a15d055 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -135,6 +135,8 @@ 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.RenderTorrent +import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader @@ -185,6 +187,8 @@ import com.vitorpamplona.quartz.events.RelaySetEvent import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent +import com.vitorpamplona.quartz.events.TorrentCommentEvent +import com.vitorpamplona.quartz.events.TorrentEvent import com.vitorpamplona.quartz.events.VideoEvent import com.vitorpamplona.quartz.events.WikiNoteEvent import kotlinx.collections.immutable.toImmutableList @@ -607,6 +611,25 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) + } else if (noteEvent is TorrentEvent) { + RenderTorrent( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } else if (noteEvent is TorrentCommentEvent) { + RenderTorrentComment( + baseNote, + false, + canPreview, + quotesLeft = 3, + unPackReply = false, + backgroundColor, + editState, + accountViewModel, + nav, + ) } else { RenderTextEvent( baseNote, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bd472d74f..5a03d1183 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -964,4 +964,9 @@ Are you sure you want to delete all drafts? " … and %1$s more" Stack: + + Torrent File + Download + Failed to open file + No torrent apps installed to open and download the file. diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt index db0274e36..d0c139705 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/DraftEvent.kt @@ -117,6 +117,21 @@ class DraftEvent( dTag: String, ): String = ATag.assembleATag(KIND, pubKey, dTag) + fun create( + dTag: String, + originalNote: TorrentCommentEvent, + 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: LiveActivitiesChatMessageEvent, @@ -125,10 +140,10 @@ class DraftEvent( onReady: (DraftEvent) -> Unit, ) { val tags = mutableListOf>() - originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag())) } - originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) } + originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag(), "", "root")) } + originalNote.replyingTo()?.let { tags.add(arrayOf("e", it, "", "reply")) } - create(dTag, originalNote, emptyList(), signer, createdAt, onReady) + create(dTag, originalNote, tags, signer, createdAt, onReady) } fun create( diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt index 3360bf94b..50b7e9955 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/Event.kt @@ -124,6 +124,8 @@ open class Event( override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } + override fun firstTag(key: String) = tags.firstOrNull { it.size > 1 && it[0] == key }?.let { it[1] } + override fun firstTagFor(vararg key: String) = tags.firstOrNull { it.size > 1 && it[0] in key }?.let { it[1] } override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt index 74f47a286..ed64ea91e 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventFactory.kt @@ -142,6 +142,8 @@ class EventFactory { StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) + TorrentEvent.KIND -> TorrentEvent(id, pubKey, createdAt, tags, content, sig) + TorrentCommentEvent.KIND -> TorrentCommentEvent(id, pubKey, createdAt, tags, content, sig) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt index db11a3ea4..ea73ebae2 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/EventInterface.kt @@ -133,6 +133,8 @@ interface EventInterface { fun taggedUrls(): List + fun firstTag(key: String): String? + fun firstTagFor(vararg key: String): String? fun firstTaggedAddress(): ATag? diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt new file mode 100644 index 000000000..cf73407af --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentCommentEvent.kt @@ -0,0 +1,141 @@ +/** + * 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.encoders.Nip92MediaAttachments +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TorrentCommentEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseTextNoteEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + private fun innerTorrent() = + tags.firstOrNull { it.size > 3 && it[0] == "e" && it[3] == "root" } + ?: tags.firstOrNull { it.size > 1 && it[0] == "e" } + + fun torrent() = innerTorrent()?.getOrNull(1) + + companion object { + const val KIND = 2004 + const val ALT = "Comment for a Torrent file" + + fun create( + message: String, + torrent: HexKey, + replyTos: List? = null, + mentions: List? = null, + addresses: List? = null, + extraTags: List? = null, + zapReceiver: List? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + markAsSensitive: Boolean, + replyingTo: String? = null, + directMentions: Set = emptySet(), + zapRaiserAmount: Long?, + geohash: String? = null, + nip94attachments: List? = null, + forkedFrom: Event? = null, + isDraft: Boolean, + onReady: (TorrentCommentEvent) -> Unit, + ) { + val content = message + + val tags = mutableListOf>() + replyTos?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "e", + root = torrent, + replyingTo = replyingTo, + directMentions = directMentions, + forkedFrom = forkedFrom?.id, + ), + ) + } + mentions?.forEach { + if (it in directMentions) { + tags.add(arrayOf("p", it, "", "mention")) + } else { + tags.add(arrayOf("p", it)) + } + } + replyTos?.forEach { + if (it in directMentions) { + tags.add(arrayOf("q", it)) + } + } + addresses + ?.map { it.toTag() } + ?.let { + tags.addAll( + it.positionalMarkedTags( + tagName = "a", + root = torrent, + replyingTo = replyingTo, + directMentions = directMentions, + forkedFrom = (forkedFrom as? AddressableEvent)?.address()?.toTag(), + ), + ) + } + findHashtags(message).forEach { + val lowercaseTag = it.lowercase() + tags.add(arrayOf("t", it)) + if (it != lowercaseTag) { + tags.add(arrayOf("t", it.lowercase())) + } + } + extraTags?.forEach { tags.add(arrayOf("t", it)) } + zapReceiver?.forEach { + tags.add(arrayOf("zap", it.lnAddressOrPubKeyHex, it.relay ?: "", it.weight.toString())) + } + findURLs(message).forEach { tags.add(arrayOf("r", it)) } + if (markAsSensitive) { + tags.add(arrayOf("content-warning", "")) + } + zapRaiserAmount?.let { tags.add(arrayOf("zapraiser", "$it")) } + geohash?.let { tags.addAll(geohashMipMap(it)) } + nip94attachments?.let { + it.forEach { + Nip92MediaAttachments().convertFromFileHeader(it)?.let { + tags.add(it) + } + } + } + tags.add(arrayOf("alt", ALT)) + + if (isDraft) { + signer.assembleRumor(createdAt, KIND, tags.toTypedArray(), content, onReady) + } else { + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } + } +} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentEvent.kt new file mode 100644 index 000000000..6d00f0c67 --- /dev/null +++ b/quartz/src/main/java/com/vitorpamplona/quartz/events/TorrentEvent.kt @@ -0,0 +1,127 @@ +/** + * 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 android.net.Uri +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.encoders.HexKey +import com.vitorpamplona.quartz.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class TorrentEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun title() = firstTag("title") + + fun btih() = firstTag("btih") + + fun x() = firstTag("x") + + fun trackers() = tags.filter { it.size > 1 && it[0] == "tracker" }.map { it[1] } + + fun files() = tags.filter { it.size > 1 && it[0] == "file" }.map { TorrentFile(it[1], it.getOrNull(2)?.toLongOrNull()) } + + fun toMagnetLink(): String { + val builder = Uri.Builder() + builder + .scheme("magnet") + .appendQueryParameter("xt", "urn:btih:${btih()}") + .appendQueryParameter("dn", title()) + + trackers().ifEmpty { DEFAULT_TRACKERS }.forEach { + builder.appendQueryParameter("tr", it) + } + + return builder.build().toString() + } + + fun totalSizeBytes(): Long = tags.filter { it.size > 1 && it[0] == "file" }.sumOf { it.getOrNull(2)?.toLongOrNull() ?: 0L } + + companion object { + const val KIND = 2003 + const val ALT_DESCRIPTION = "A torrent file" + + val DEFAULT_TRACKERS = + listOf( + "http://tracker.loadpeers.org:8080/xvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX/announce", + "udp://tracker.coppersurfer.tk:6969/announce", + "udp://tracker.openbittorrent.com:6969/announce", + "udp://open.stealth.si:80/announce", + "udp://tracker.torrent.eu.org:451/announce", + "udp://tracker.opentrackr.org:1337", + ) + + fun create( + title: String, + btih: String, + files: List, + description: String? = null, + x: String? = null, + trackers: List? = null, + alt: String? = null, + sensitiveContent: Boolean? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (TorrentEvent) -> Unit, + ) { + val tags = + listOfNotNull( + arrayOf("title", title), + arrayOf("btih", btih), + x?.let { arrayOf("x", it) }, + alt?.let { arrayOf("alt", it) } ?: arrayOf("alt", ALT_DESCRIPTION), + sensitiveContent?.let { + if (it) { + arrayOf("content-warning", "") + } else { + null + } + }, + ) + + files.map { + if (it.bytes != null) { + arrayOf(it.fileName, it.bytes.toString()) + } else { + arrayOf(it.fileName) + } + } + + ( + trackers?.map { + arrayOf(it) + } ?: emptyList() + ) + + val content = description ?: "" + signer.sign(createdAt, KIND, tags.toTypedArray(), content, onReady) + } + } +} + +class TorrentFile( + val fileName: String, + val bytes: Long?, +)