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

View File

@ -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<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) {
val key = DraftEvent.createAddressTag(userProfile().pubkeyHex, draftTag)
LocalCache.getAddressableNoteIfExists(key)?.let { note ->

View File

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

View File

@ -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<Note>()
@ -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),

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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),
)
}
}

View File

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

View File

@ -964,4 +964,9 @@
<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="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>

View File

@ -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<Array<String>>()
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(

View File

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

View File

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

View File

@ -133,6 +133,8 @@ interface EventInterface {
fun taggedUrls(): List<String>
fun firstTag(key: String): String?
fun firstTagFor(vararg key: String): String?
fun firstTaggedAddress(): ATag?

View File

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

View File

@ -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?,
)