Adds support for NIP-35 torrents and their comments

This commit is contained in:
Vitor Pamplona
2024-08-21 20:31:59 -04:00
parent 83f1e523ea
commit a05ad6e686
18 changed files with 1127 additions and 5 deletions
@@ -102,6 +102,7 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.WrappedEvent import com.vitorpamplona.quartz.events.WrappedEvent
import com.vitorpamplona.quartz.events.ZapSplitSetup import com.vitorpamplona.quartz.events.ZapSplitSetup
import com.vitorpamplona.quartz.signers.NostrSigner import com.vitorpamplona.quartz.signers.NostrSigner
@@ -1796,6 +1797,78 @@ class Account(
} }
} }
fun sendTorrentComment(
message: String,
replyTo: List<Note>?,
mentions: List<User>?,
zapReceiver: List<ZapSplitSetup>? = null,
wantsToMarkAsSensitive: Boolean,
zapRaiserAmount: Long? = null,
replyingTo: String?,
root: String,
directMentions: Set<HexKey>,
forkedFrom: Event?,
relayList: List<RelaySetupInfo>? = null,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = 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) { fun deleteDraft(draftTag: String) {
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag) val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
LocalCache.getAddressableNoteIfExists(key)?.let { note -> LocalCache.getAddressableNoteIfExists(key)?.let { note ->
@@ -114,6 +114,8 @@ import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.StatusEvent import com.vitorpamplona.quartz.events.StatusEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent
@@ -463,6 +465,61 @@ object LocalCache {
refreshObservers(note) 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( fun consume(
event: NIP90ContentDiscoveryResponseEvent, event: NIP90ContentDiscoveryResponseEvent,
relay: Relay? = null, relay: Relay? = null,
@@ -795,6 +852,8 @@ object LocalCache {
.tagsWithoutCitations() .tagsWithoutCitations()
.filter { it != event.activity()?.toTag() } .filter { it != event.activity()?.toTag() }
.mapNotNull { checkGetOrCreateNote(it) } .mapNotNull { checkGetOrCreateNote(it) }
is TorrentCommentEvent ->
event.tagsWithoutCitations().mapNotNull { checkGetOrCreateNote(it) }
is DraftEvent -> { is DraftEvent -> {
event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) } event.mapTaggedEvent { checkGetOrCreateNote(it) } + event.mapTaggedAddress { checkGetOrCreateAddressableNote(it) }
@@ -1303,6 +1362,10 @@ object LocalCache {
getChannelIfExists(it.toTag())?.removeNote(deleteNote) getChannelIfExists(it.toTag())?.removeNote(deleteNote)
} }
(deletedEvent as? TorrentCommentEvent)?.torrent()?.let {
getNoteIfExists(it)?.removeReply(deleteNote)
}
if (deletedEvent is PrivateDmEvent) { if (deletedEvent is PrivateDmEvent) {
val author = deleteNote.author val author = deleteNote.author
val recipient = val recipient =
@@ -2664,6 +2727,8 @@ object LocalCache {
is StatusEvent -> consume(event, relay) is StatusEvent -> consume(event, relay)
is TextNoteEvent -> consume(event, relay) is TextNoteEvent -> consume(event, relay)
is TextNoteModificationEvent -> consume(event, relay) is TextNoteModificationEvent -> consume(event, relay)
is TorrentEvent -> consume(event, relay)
is TorrentCommentEvent -> consume(event, relay)
is VideoHorizontalEvent -> consume(event, relay) is VideoHorizontalEvent -> consume(event, relay)
is VideoVerticalEvent -> consume(event, relay) is VideoVerticalEvent -> consume(event, relay)
is WikiNoteEvent -> consume(event, relay) is WikiNoteEvent -> consume(event, relay)
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent import com.vitorpamplona.quartz.events.TextNoteModificationEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") { object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
private var nextEventsToWatch = setOf<Note>() private var nextEventsToWatch = setOf<Note>()
@@ -179,6 +180,7 @@ object NostrSingleEventDataSource : AmethystNostrDataSource("SingleEventFeed") {
DeletionEvent.KIND, DeletionEvent.KIND,
NIP90ContentDiscoveryResponseEvent.KIND, NIP90ContentDiscoveryResponseEvent.KIND,
NIP90StatusEvent.KIND, NIP90StatusEvent.KIND,
TorrentCommentEvent.KIND,
), ),
tags = mapOf("e" to it.map { it.idHex }), tags = mapOf("e" to it.map { it.idHex }),
since = findMinimumEOSEs(it), since = findMinimumEOSEs(it),
@@ -42,6 +42,8 @@ import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.events.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent
object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") { 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() = fun createUserReceivedZapsFilter() =
user?.let { user?.let {
TypedFilter( TypedFilter(
@@ -188,6 +207,7 @@ object NostrUserProfileDataSource : AmethystNostrDataSource("UserProfileFeed") {
listOfNotNull( listOfNotNull(
createUserInfoFilter(), createUserInfoFilter(),
createUserPostsFilter(), createUserPostsFilter(),
createUserPostsFilter2(),
createProfileGalleryFilter(), createProfileGalleryFilter(),
createFollowFilter(), createFollowFilter(),
createFollowersFilter(), createFollowersFilter(),
@@ -69,6 +69,8 @@ import com.vitorpamplona.quartz.events.GitIssueEvent
import com.vitorpamplona.quartz.events.Price import com.vitorpamplona.quartz.events.Price
import com.vitorpamplona.quartz.events.PrivateDmEvent import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.TextNoteEvent 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.ZapSplitSetup
import com.vitorpamplona.quartz.events.findURLs import com.vitorpamplona.quartz.events.findURLs
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -681,6 +683,74 @@ open class NewPostViewModel : ViewModel() {
nip94attachments = usedAttachments, nip94attachments = usedAttachments,
draftTag = localDraft, 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 { } else {
if (wantsPoll) { if (wantsPoll) {
account?.sendPoll( account?.sendPoll(
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.events.ChannelMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentCommentEvent
class UserProfileConversationsFeedFilter( class UserProfileConversationsFeedFilter(
val user: User, val user: User,
@@ -59,7 +60,8 @@ class UserProfileConversationsFeedFilter(
it.event is TextNoteEvent || it.event is TextNoteEvent ||
it.event is PollNoteEvent || it.event is PollNoteEvent ||
it.event is ChannelMessageEvent || it.event is ChannelMessageEvent ||
it.event is LiveActivitiesChatMessageEvent it.event is LiveActivitiesChatMessageEvent ||
it.event is TorrentCommentEvent
) && ) &&
!it.isNewThread() && !it.isNewThread() &&
account.isAcceptable(it) account.isAcceptable(it)
@@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.events.LongTextNoteEvent
import com.vitorpamplona.quartz.events.PollNoteEvent import com.vitorpamplona.quartz.events.PollNoteEvent
import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent
class UserProfileNewThreadFeedFilter( class UserProfileNewThreadFeedFilter(
@@ -73,7 +74,8 @@ class UserProfileNewThreadFeedFilter(
it.event is PollNoteEvent || it.event is PollNoteEvent ||
it.event is HighlightEvent || it.event is HighlightEvent ||
it.event is AudioTrackEvent || it.event is AudioTrackEvent ||
it.event is AudioHeaderEvent it.event is AudioHeaderEvent ||
it.event is TorrentEvent
) && ) &&
it.isNewThread() && it.isNewThread() &&
account.isAcceptable(it) account.isAcceptable(it)
@@ -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.RenderReport
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent 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.RenderWikiContent
import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.SearchRelayListEvent
import com.vitorpamplona.quartz.events.TextNoteEvent import com.vitorpamplona.quartz.events.TextNoteEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent 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.VideoEvent
import com.vitorpamplona.quartz.events.VideoHorizontalEvent import com.vitorpamplona.quartz.events.VideoHorizontalEvent
import com.vitorpamplona.quartz.events.VideoVerticalEvent import com.vitorpamplona.quartz.events.VideoVerticalEvent
@@ -754,6 +758,27 @@ private fun RenderNoteRow(
accountViewModel, accountViewModel,
nav, nav,
) )
is TorrentEvent ->
RenderTorrent(
baseNote,
backgroundColor,
accountViewModel,
nav,
)
is TorrentCommentEvent ->
RenderTorrentComment(
baseNote,
makeItShort,
canPreview,
quotesLeft,
unPackReply,
backgroundColor,
editState,
accountViewModel,
nav,
)
else -> { else -> {
RenderTextEvent( RenderTextEvent(
baseNote, baseNote,
@@ -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<Color>,
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<TorrentFile>,
name: String,
description: String?,
link: () -> String,
backgroundColor: MutableState<Color>,
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 }
}
}
}
}
}
@@ -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<Color>,
editState: State<GenericLoadable<EditState>>,
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),
)
}
}
@@ -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.RenderPrivateMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent 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.note.types.VideoDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ChannelHeader 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.RepostEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent
import com.vitorpamplona.quartz.events.TextNoteModificationEvent 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.VideoEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent import com.vitorpamplona.quartz.events.WikiNoteEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -607,6 +611,25 @@ private fun FullBleedNoteCompose(
accountViewModel, accountViewModel,
nav, 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 { } else {
RenderTextEvent( RenderTextEvent(
baseNote, baseNote,
+5
View File
@@ -964,4 +964,9 @@
<string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string> <string name="delete_all_drafts_confirmation">Are you sure you want to delete all drafts?</string>
<string name="and_more">" … and %1$s more"</string> <string name="and_more">" … and %1$s more"</string>
<string name="stack">Stack:</string> <string name="stack">Stack:</string>
<string name="torrent_file">Torrent File</string>
<string name="torrent_download">Download</string>
<string name="torrent_failure">Failed to open file</string>
<string name="torrent_no_apps">No torrent apps installed to open and download the file.</string>
</resources> </resources>
@@ -117,6 +117,21 @@ class DraftEvent(
dTag: String, dTag: String,
): String = ATag.assembleATag(KIND, pubKey, dTag) ): 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( fun create(
dTag: String, dTag: String,
originalNote: LiveActivitiesChatMessageEvent, originalNote: LiveActivitiesChatMessageEvent,
@@ -125,10 +140,10 @@ class DraftEvent(
onReady: (DraftEvent) -> Unit, onReady: (DraftEvent) -> Unit,
) { ) {
val tags = mutableListOf<Array<String>>() val tags = mutableListOf<Array<String>>()
originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag())) } originalNote.activity()?.let { tags.add(arrayOf("a", it.toTag(), "", "root")) }
originalNote.replyingTo()?.let { tags.add(arrayOf("e", it)) } 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( fun create(
@@ -124,6 +124,8 @@ open class Event(
override fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] } 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 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] } override fun firstTaggedUser() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.let { it[1] }
@@ -142,6 +142,8 @@ class EventFactory {
StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig) StatusEvent.KIND -> StatusEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig) TextNoteEvent.KIND -> TextNoteEvent(id, pubKey, createdAt, tags, content, sig)
TextNoteModificationEvent.KIND -> TextNoteModificationEvent(id, pubKey, createdAt, tags, content, sig) 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) VideoHorizontalEvent.KIND -> VideoHorizontalEvent(id, pubKey, createdAt, tags, content, sig)
VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig) VideoVerticalEvent.KIND -> VideoVerticalEvent(id, pubKey, createdAt, tags, content, sig)
VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig) VideoViewEvent.KIND -> VideoViewEvent(id, pubKey, createdAt, tags, content, sig)
@@ -133,6 +133,8 @@ interface EventInterface {
fun taggedUrls(): List<String> fun taggedUrls(): List<String>
fun firstTag(key: String): String?
fun firstTagFor(vararg key: String): String? fun firstTagFor(vararg key: String): String?
fun firstTaggedAddress(): ATag? fun firstTaggedAddress(): ATag?
@@ -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<Array<String>>,
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<String>? = null,
mentions: List<String>? = null,
addresses: List<ATag>? = null,
extraTags: List<String>? = null,
zapReceiver: List<ZapSplitSetup>? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
markAsSensitive: Boolean,
replyingTo: String? = null,
directMentions: Set<HexKey> = emptySet(),
zapRaiserAmount: Long?,
geohash: String? = null,
nip94attachments: List<FileHeaderEvent>? = null,
forkedFrom: Event? = null,
isDraft: Boolean,
onReady: (TorrentCommentEvent) -> Unit,
) {
val content = message
val tags = mutableListOf<Array<String>>()
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)
}
}
}
}
@@ -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<Array<String>>,
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<TorrentFile>,
description: String? = null,
x: String? = null,
trackers: List<String>? = 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?,
)